19
21

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

1人フロントエンドAdvent Calendar 2022

Day 10

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

Posted at

目標

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

Fileオブジェクト

ファイルには5つのプロパティがあります(Deprecatedされたものは除いてます)。

  • lastModified: ファイルの最終更新時刻
  • name: ファイルの名前
  • webkitRelativePath: Fileの相対パス(標準化されてません)
  • size: ファイルのサイズをバイト単位
  • type: ファイルのMIME タイプ

inputの動作確認

お手本とするinputの動作を確認します。ファイルだけを扱う場合とフォルダだけを扱う場合で挙動が異なるので分けて紹介します。

ファイル

複数ファイルをinputする場合は属性にmultipleを与えることでできます。webkitRelativePathには空文字列が入ります。下のサンプルでは選択したファイルのnameが表示されます。

See the Pen Untitled by KokiSakano (@kokisakano) on CodePen.

フォルダ

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

See the Pen input multiple by KokiSakano (@kokisakano) on CodePen.

D&Dを実装する

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

dragover

onDragOverという名前でdragoverの実装を紹介します。

const onDragOver = (event) => event.preventDefault();

drop実装

onDropという名前で実装します。最終的な実装は以下の通りです。D&Dされたファイルを操作してinputと同じような情報を持たせます。最終的にファイルに対してしたい操作を外部でhandleFilesとして定義されたものを実行します。

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は呼び出されるまで置いておいて、まずは以下の部分を解説します。

const items = event.dataTransfer.items;

dropによって発火するイベントはDragEventです。DragEventdataTransferプロパティによってD&Dで操作しているデータを扱うオブジェクトにアクセスすることができます。さらにitemsにアクセスして全てのD&Dしたデータを取得します。filesにアクセスすることでファイルデータは手に入りますが、ディレクトリ構造などは手に入らないのでitemsから情報を取得しました。
次にcalcFullPathPerItemです。

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関数によってFileSystemFileEntryFileSystemDirectoryEntryに変換します。この二つは合わせてオブジェクトのFileSystemEntryとして扱われます。itemがファイルでない場合はnullが返されます。ファイル以外は扱わないので何もせずにresolveして早期returnします。最後にsearchFilesで先ほど変換したFileSystemFileEntryまたはFileSystemDirectoryEntryを引数に取って、filesに詰めていきます。
最後にcalcFullPathPerItemsで作成されたPromiseを全て解決させて、それによって詰め込まれたfileshandleFilesに挿入して終わりです。

await Promise.all(calcFullPathPerItems);
handleFiles(files);

説明を忘れていましたが、一番コアなseachFilesを説明します。

// 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にファイルを詰め込んでいきます。まずはファイルかディレクトリか分けます。判別はisFileisDirectoryの結果によって行います。TypeScriptで扱う場合はこの判別をしても常にFileSystemEntryとして扱われるのでFileSystemFileEntryまたはFileSystemDirectoryEntryの片方だけに依存する処理を行いたい時は型アサーションみたいなものを作った方が良いです。私は下のように書きます。

const isFileSystemFileEntry = (
  entry: FileSystemEntry
): entry is FileSystemFileEntry => {
  return entry.isFile;
};
const isFileSystemFileEntry = (
  entry: FileSystemEntry
): entry is FileSystemDirectoryEntry => {
  return entry.isDirectory;
};

ここではJavaScriptなのでisFileisDirectoryで行いました。
まずはFileの処理です。

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の処理です。

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);
}

ディレクトリが渡されるので、その中にあるファイルを取得できるまで掘ります。まず、FileSystemDirectoryEntrycreateReaderを読んでReaderを作ります。getEntries関数はReaderのreadEntriesメソッドを呼んでそのディレクトリを構成するFileSystemEntryの配列を返します。readEntriesは100件ずつしか取得してくれないので空になるまで取り出す必要があります。readAllEntriesは再起的getEntriesで0件になるまで取得します。そうやって取得したFileSystemEntryの配列を一つずつsearchFileに代入してファイルだけになるまで繰り返されます。

これを用いて作成したデモこちらです。

See the Pen D&D multiple by KokiSakano (@kokisakano) on CodePen.

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

19
21
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
21

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?