dropTraversal.ts3.9 KBView on GitHub
/**
 * Walks a native browser drop event and produces a flat list of files plus the
 * relative path from the dropped root. Handles both file drops and folder drops
 * via `DataTransferItem.webkitGetAsEntry()` (supported in all modern browsers).
 */

export interface DroppedFile {
  file: File;
  /** Posix-style path relative to the drop root. e.g. "research/acme/notes.md" */
  relativePath: string;
}

interface FileSystemEntryLike {
  isFile: boolean;
  isDirectory: boolean;
  name: string;
  fullPath: string;
  file?: (cb: (file: File) => void, err?: (e: unknown) => void) => void;
  createReader?: () => {
    readEntries: (cb: (entries: FileSystemEntryLike[]) => void, err?: (e: unknown) => void) => void;
  };
}

/**
 * Read all DataTransferItems and return a flat list of files. Folders are
 * recursively traversed. The relative path on each entry mirrors the dropped
 * folder structure so callers can recreate it.
 */
export async function readDroppedItems(items: DataTransferItemList): Promise<DroppedFile[]> {
  const roots: FileSystemEntryLike[] = [];
  for (let i = 0; i < items.length; i++) {
    const item = items[i];
    if (item.kind !== 'file') continue;
    // webkitGetAsEntry is the only cross-browser way to detect folders today.
    const getAsEntry = (item as DataTransferItem & {
      webkitGetAsEntry?: () => FileSystemEntryLike | null;
    }).webkitGetAsEntry;
    const entry = typeof getAsEntry === 'function' ? getAsEntry.call(item) : null;
    if (entry) {
      roots.push(entry);
      continue;
    }
    // Browser without webkitGetAsEntry — fall back to the plain File.
    const file = item.getAsFile();
    if (file) {
      // Manufacture a fake entry so the loop below can treat everything uniformly.
      roots.push({
        isFile: true,
        isDirectory: false,
        name: file.name,
        fullPath: '/' + file.name,
        file: (cb) => cb(file),
      });
    }
  }

  const out: DroppedFile[] = [];
  for (const root of roots) {
    await walkEntry(root, '', out);
  }
  return out;
}

async function walkEntry(
  entry: FileSystemEntryLike,
  parentPath: string,
  out: DroppedFile[],
): Promise<void> {
  if (entry.isFile) {
    const file = await entryToFile(entry);
    if (file) {
      out.push({
        file,
        relativePath: parentPath ? `${parentPath}/${entry.name}` : entry.name,
      });
    }
    return;
  }
  if (!entry.isDirectory || !entry.createReader) return;

  const reader = entry.createReader();
  // readEntries can return < all entries per call; loop until it returns [].
  let batch: FileSystemEntryLike[] = await readBatch(reader);
  const childPath = parentPath ? `${parentPath}/${entry.name}` : entry.name;
  while (batch.length > 0) {
    for (const child of batch) {
      await walkEntry(child, childPath, out);
    }
    batch = await readBatch(reader);
  }
}

function readBatch(reader: {
  readEntries: (cb: (entries: FileSystemEntryLike[]) => void, err?: (e: unknown) => void) => void;
}): Promise<FileSystemEntryLike[]> {
  return new Promise((resolve) => {
    reader.readEntries(
      (entries) => resolve(entries),
      () => resolve([]),
    );
  });
}

function entryToFile(entry: FileSystemEntryLike): Promise<File | null> {
  return new Promise((resolve) => {
    if (typeof entry.file !== 'function') {
      resolve(null);
      return;
    }
    entry.file(
      (file) => resolve(file),
      () => resolve(null),
    );
  });
}

/** Returns true if any of the items represent a directory drop. */
export function dropContainsDirectories(items: DataTransferItemList): boolean {
  for (let i = 0; i < items.length; i++) {
    const item = items[i];
    if (item.kind !== 'file') continue;
    const getAsEntry = (item as DataTransferItem & {
      webkitGetAsEntry?: () => FileSystemEntryLike | null;
    }).webkitGetAsEntry;
    const entry = typeof getAsEntry === 'function' ? getAsEntry.call(item) : null;
    if (entry?.isDirectory) return true;
  }
  return false;
}