目標
この記事では指定したフィールド上にファイルやフォルダをドラッグ&ドロップ(以後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はdrop
とdragover
イベントで実装します。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
です。DragEvent
はdataTransfer
プロパティによって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
関数によってFileSystemFileEntry
とFileSystemDirectoryEntry
に変換します。この二つは合わせてオブジェクトのFileSystemEntry
として扱われます。itemがファイルでない場合はnullが返されます。ファイル以外は扱わないので何もせずにresolveして早期returnします。最後にsearchFiles
で先ほど変換したFileSystemFileEntry
またはFileSystemDirectoryEntry
を引数に取って、files
に詰めていきます。
最後にcalcFullPathPerItems
で作成されたPromiseを全て解決させて、それによって詰め込まれたfiles
をhandleFiles
に挿入して終わりです。
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
にファイルを詰め込んでいきます。まずはファイルかディレクトリか分けます。判別はisFile
とisDirectory
の結果によって行います。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なのでisFile
とisDirectory
で行いました。
まずは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);
}
ディレクトリが渡されるので、その中にあるファイルを取得できるまで掘ります。まず、FileSystemDirectoryEntry
にcreateReader
を読んでReaderを作ります。getEntries
関数はReaderのreadEntries
メソッドを呼んでそのディレクトリを構成するFileSystemEntry
の配列を返します。readEntries
は100件ずつしか取得してくれないので空になるまで取り出す必要があります。readAllEntries
は再起的getEntries
で0件になるまで取得します。そうやって取得したFileSystemEntry
の配列を一つずつsearchFile
に代入してファイルだけになるまで繰り返されます。
これを用いて作成したデモこちらです。
See the Pen D&D multiple by KokiSakano (@kokisakano) on CodePen.
ファイルだけ、フォルダだけで扱いたいときはそれぞれの分岐を消すことで片方しか実行されなくなります。