ドラッグ&ドロップでファイルとフォルダをアップロードする

# 目標
この記事では指定したフィールド上にファイルやフォルダをドラッグ&ドロップ(以後D&Dと呼びます)するとinputでファイルやフォルダを複数アップロードした時と同じ結果を得られるようにすることを目標とします。

# Fileオブジェクト
ファイルには5つのプロパティがあります(Deprecatedされたものは除いてます)。
- `lastModified`: ファイルの最終更新時刻
- `name`: ファイルの名前
- `webkitRelativePath`: Fileの相対パス(標準化されてません)
- `size`: ファイルのサイズをバイト単位
- `type`: ファイルのMIME タイプ

# inputの動作確認
お手本とするinputの動作を確認します。ファイルだけを扱う場合とフォルダだけを扱う場合で挙動が異なるので分けて紹介します。
## ファイル
複数ファイルをinputする場合は属性にmultipleを与えることでできます。`webkitRelativePath`には空文字列が入ります。下のサンプルでは選択したファイルの`name`が表示されます。

<p class="codepen" data-height="300" data-default-tab="result" data-slug-hash="YzvMQMB" data-editable="true" data-user="kokisakano" style="height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/kokisakano/pen/YzvMQMB">
  Untitled</a> by KokiSakano (<a href="https://codepen.io/kokisakano">@kokisakano</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

## フォルダ
フォルダを選択するときは属性にwebkitdirectoryを与えることができます。この機能は標準化されておらず、Firefox Androidでは使えないことに注意して下さい。`webkitRelativePath`には選択したフォルダからの相対パスが入ります。下のサンプルでは`webkitRelativePath`が表示されるので動作が気になる方は確認して下さい。

<p class="codepen" data-height="300" data-default-tab="result" data-slug-hash="xxzeLbJ" data-editable="true" data-user="kokisakano" style="height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/kokisakano/pen/xxzeLbJ">
  input multiple</a> by KokiSakano (<a href="https://codepen.io/kokisakano">@kokisakano</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

# D＆Dを実装する
D＆Dは`drop`と`dragover`イベントで実装します。`dragover`はデフォルトのイベントであるドラッグ操作を無効にする動作を発火させたくないので無効にします。そして、`drop`にはドロップされてきたファイルの扱いを記述します。inputに入力するファイルとは異なり、D&Dされてくるファイルとフォルダの区別ができないので両方の機能を持つエリアを作成します。どちらかに限定したい場合については後から紹介します。

## dragover
`onDragOver`という名前で`dragover`の実装を紹介します。
```javascript
const onDragOver = (event) => event.preventDefault();
```

## drop実装
`onDrop`という名前で実装します。最終的な実装は以下の通りです。D&Dされたファイルを操作してinputと同じような情報を持たせます。最終的にファイルに対してしたい操作を外部で`handleFiles`として定義されたものを実行します。
```javascript
const onDrop = async (event) => {
  event.preventDefault();

  // filesの初期化
  const files = [];

  // 最上階層から再起的に低い階層へファイルを取得するまで呼び出す
  const searchFile = async (entry) => {
    // ファイルのwebkitRelativePathにパスを登録する
    if (entry.isFile) {
      const file = await new Promise((resolve) => {
        entry.file((file) => {
          resolve(file);
        });
      });
      files.push(file);
      // ファイルが現れるまでこちらの分岐をループし続ける
    } else if (entry.isDirectory) {
      const dirReader = entry.createReader();
      let allEntries = [];
      const getEntries = () =>
        new Promise((resolve) => {
          dirReader.readEntries((entries) => {
            resolve(entries);
          });
        });
      // readEntriesは100件ずつの取得なので、再帰で0件になるまで取ってくるようにする
      // https://developer.mozilla.org/en-US/docs/Web/API/FileSystemDirectoryReader/readEntries
      const readAllEntries = async () => {
        const entries = await getEntries();
        if (entries.length > 0) {
          allEntries = allEntries.concat(entries);
          await readAllEntries();
        }
      };
      await readAllEntries();
      for (const entry of allEntries) {
        await searchFile(entry);
      }
    }
  };

  const items = event.dataTransfer.items;
  const calcFullPathPerItems = Array.from(items).map((item) => {
    return new Promise((resolve) => {
      const entry = item.webkitGetAsEntry();
      // nullの時は何もしない
      if (!entry) {
        resolve;
        return;
      }
      resolve(searchFile(entry));
    });
  });

  await Promise.all(calcFullPathPerItems);
  handleFiles(files);
};
```
`searchFile`は呼び出されるまで置いておいて、まずは以下の部分を解説します。
```js
const items = event.dataTransfer.items;
```
`drop`によって発火するイベントは`DragEvent`です。`DragEvent`は`dataTransfer`プロパティによってD&Dで操作しているデータを扱うオブジェクトにアクセスすることができます。さらに`items`にアクセスして全てのD&Dしたデータを取得します。`files`にアクセスすることでファイルデータは手に入りますが、ディレクトリ構造などは手に入らないので`items`から情報を取得しました。
次に`calcFullPathPerItem`です。
```js
const calcFullPathPerItems = Array.from(items).map((item) => {
  return new Promise((resolve) => {
    const entry = item.webkitGetAsEntry();
    // nullの時は何もしない
    if (!entry) {
      resolve;
      return;
    }
    resolve(searchFile(entry));
  });
});
```
この関数はPromiseの配列を返します。itemsを配列に変換してD&Dによって扱うデータでmapで展開してPromiseの配列を作ります。Promiseの中では、itemを`webkitGetAsEntry`関数によって`FileSystemFileEntry`と`FileSystemDirectoryEntry`に変換します。この二つは合わせてオブジェクトの`FileSystemEntry`として扱われます。itemがファイルでない場合はnullが返されます。ファイル以外は扱わないので何もせずにresolveして早期returnします。最後に`searchFiles`で先ほど変換した`FileSystemFileEntry`または`FileSystemDirectoryEntry`を引数に取って、`files`に詰めていきます。
最後に`calcFullPathPerItems`で作成されたPromiseを全て解決させて、それによって詰め込まれた`files`を`handleFiles`に挿入して終わりです。
```js
await Promise.all(calcFullPathPerItems);
handleFiles(files);
```

説明を忘れていましたが、一番コアな`seachFiles`を説明します。
```js
// filesの初期化
const files = [];

// 最上階層から再起的に低い階層へファイルを取得するまで呼び出す
const searchFile = async (entry) => {
  // ファイルのwebkitRelativePathにパスを登録する
  if (entry.isFile) {
    const file = await new Promise((resolve) => {
      entry.file((file) => {
        resolve(file);
      });
    });
    files.push(file);
    // ファイルが現れるまでこちらの分岐をループし続ける
  } else if (entry.isDirectory) {
    const dirReader = entry.createReader();
    let allEntries = [];
    const getEntries = () =>
      new Promise((resolve) => {
        dirReader.readEntries((entries) => {
          resolve(entries);
        });
      });
    // readEntriesは100件ずつの取得なので、再帰で0件になるまで取ってくるようにする
    // https://developer.mozilla.org/en-US/docs/Web/API/FileSystemDirectoryReader/readEntries
    const readAllEntries = async () => {
      const entries = await getEntries();
      if (entries.length > 0) {
        allEntries = [...allEntries, ...entries];
        await readAllEntries();
      }
    };
    await readAllEntries();
    for (const entry of allEntries) {
      await searchFile(entry);
    }
  }
};
```
外部で定義した`files`にファイルを詰め込んでいきます。まずはファイルかディレクトリか分けます。判別は`isFile`と`isDirectory`の結果によって行います。TypeScriptで扱う場合はこの判別をしても常に`FileSystemEntry`として扱われるので`FileSystemFileEntry`または`FileSystemDirectoryEntry`の片方だけに依存する処理を行いたい時は型アサーションみたいなものを作った方が良いです。私は下のように書きます。
```ts
const isFileSystemFileEntry = (
  entry: FileSystemEntry
): entry is FileSystemFileEntry => {
  return entry.isFile;
};
const isFileSystemFileEntry = (
  entry: FileSystemEntry
): entry is FileSystemDirectoryEntry => {
  return entry.isDirectory;
};
```
ここではJavaScriptなので`isFile`と`isDirectory`で行いました。
まずはFileの処理です。
```js
const file = await new Promise((resolve) => {
  entry.file((file) => {
    Object.defineProperty(file, "webkitRelativePath", {
      // fullPathは/から始まるので二文字目から抜き出す
      value: entry.fullPath.slice(1),
    });
    resolve(file);
  });
});
files.push(file);
```
file関数でファイルを取得して、filesに追加します。file関数はfileの準備ができたらコールバック関数を実行する関数なので、準備ができたら解決するPromise関数を生成しています。`webkitRelativePath`に値をついでに定義しています。このコードだとファイルだけの時でもファイル名になって空文字列にならないので、そこの違いが気になる場合は分岐する必要があります(/の個数でできると思います)。`fullPath`は/から始まるので、それを除いた文字列を渡しています。
最後にDirectoryの処理です。
```js
const dirReader = entry.createReader();
let allEntries = [];
const getEntries = () =>
  new Promise((resolve) => {
    dirReader.readEntries((entries) => {
      resolve(entries);
    });
  });
// readEntriesは100件ずつの取得なので、再帰で0件になるまで取ってくるようにする
// https://developer.mozilla.org/en-US/docs/Web/API/FileSystemDirectoryReader/readEntries
const readAllEntries = async () => {
  const entries = await getEntries();
  if (entries.length > 0) {
    allEntries = [...allEntries, ...entries];
    await readAllEntries();
  }
};
await readAllEntries();
for (const entry of allEntries) {
  await searchFile(entry);
}
```
ディレクトリが渡されるので、その中にあるファイルを取得できるまで掘ります。まず、`FileSystemDirectoryEntry`に`createReader`を読んでReaderを作ります。`getEntries`関数はReaderの`readEntries`メソッドを呼んでそのディレクトリを構成する`FileSystemEntry`の配列を返します。`readEntries`は100件ずつしか取得してくれないので空になるまで取り出す必要があります。`readAllEntries`は再起的`getEntries`で0件になるまで取得します。そうやって取得した`FileSystemEntry`の配列を一つずつ`searchFile`に代入してファイルだけになるまで繰り返されます。

これを用いて作成したデモこちらです。
<p class="codepen" data-height="300" data-default-tab="result" data-slug-hash="QWxPMpv" data-editable="true" data-user="kokisakano" style="height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/kokisakano/pen/QWxPMpv">
  D&amp;D multiple</a> by KokiSakano (<a href="https://codepen.io/kokisakano">@kokisakano</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

ファイルだけ、フォルダだけで扱いたいときはそれぞれの分岐を消すことで片方しか実行されなくなります。