こんにちは、切削チームの石川です。 前回に引き続き、フォルダアップロードに絡んだフロントエンドのテクニックの解説を行います。
今回のテーマはドラッグ&ドロップです。
※前回の記事は以下です。
ドラッグ&ドロップの基本
ブラウザにおけるドラッグやドロップに関する処理は、HTML Drag and Drop APIを使うことで実装できます。
HTML ドラッグ & ドロップ API https://developer.mozilla.org/ja/docs/Web/API/HTML_Drag_and_Drop_API
ブラウザにファイルをドロップしたり要素をドラッグするのは、ほとんどの方が何かしらのWebサービスで使った経験があるかと思います。
例えばタスク管理ツールにはタスクをドラッグ&ドロップして状態を更新する機能が付いています。
要素をクリックして「対応中」「完了」などのグループにドロップしようとした際に要素の枠線が太くなるなどUIが変わりますが、それらは要素へのドラッグやドロップに関するイベントハンドラを定義することで実現できます。
meviyではプロジェクト一覧画面(ログイン直後の画面)でファイルやフォルダをドロップしようとすると「ここに3D CADファイルまたは、フォルダをドロップしてください」と表示が出ますが、これもドロップイベントのハンドラを定義すると実現できます。
ドラッグ&ドロップやフォルダのドロップではDataTransferでデータを変換する処理が出てくるので軽く解説し、その後にサンプルで説明を深堀りします。
ドラッグ&ドロップで出てくる用語
ドラッグイベント
ドラッグ&ドロップを扱う際には、dropやdragenterといった各種ドラッグイベントを要素にaddEventListenerして使います。
イベントの付与対象はdocumentだけでなく、特定の要素を指定することも可能です。
ドロップした際の処理はdropイベントで記述し、ドラッグしている最中の処理はdragやdragenterなどを使って書きます。
要素にaddEventListenerして記述する形式なので、要素ごとに別々の処理を書くことも可能です。
ブラウザで使えるドラッグイベントの一覧は以下のページを参考にしてください。
https://developer.mozilla.org/ja/docs/Web/API/HTML_Drag_and_Drop_API
DataTransfer
DataTransfer
https://developer.mozilla.org/ja/docs/Web/API/DataTransfer
DataTransfer オブジェクトは、ドラッグ&ドロップ操作中にドラッグされているデータを保持するために使用されます。
このオブジェクトはすべての ドラッグイベント の dataTransfer プロパティからアクセスすることができます。
ドラッグやドロップを扱う際は DataTransfer を通じて各種データを取得します。
DataTransfer配下にはitemsなどのプロパティが存在し、ドラッグ・ドロップされた内容はそれらプロパティから取得できます(実際の取得の様子は後述するサンプルコードを参照)。
ファイルやフォルダのドロップに関していうと、ファイルをドロップして扱う場合は DataTransfer.files でファイルのデータを扱えますが、フォルダも扱いたい・フォルダの中を辿って処理したい場合は DataTransfer.items を使う必要があります。
DataTransfer.files
https://developer.mozilla.org/ja/docs/Web/API/DataTransfer/files
DataTransfer.items
https://developer.mozilla.org/ja/docs/Web/API/DataTransfer/items
ファイルをドロップした場合は DataTransfer.files の中身はFileListになるので、前回の記事と同じように処理すればOKです(ファイルのバイナリを読み込んだりして処理できます)。
フォルダドロップした場合のサンプルコードに関しては後述します。
FileSystemEntry
ドロップした結果を DataTransfer.files で取得すると DataTransferItemList が得られます。
配下には DataTransferItem オブジェクトが存在し、 Array.from で配列化することで普通の配列と同じようにmapメソッドなどを使えます。
DataTransferItem
https://developer.mozilla.org/ja/docs/Web/API/DataTransferItem
DataTransferItem に生えている webkitGetAsEntry メソッドを使うことでドロップされた項目を FileSystemEntry ベースのオブジェクトに変換でき、ドロップされた項目のうち
- ファイルは FileSystemFileEntry
- ディレクトリは FileSystemDirectoryEntry
に変換されます。
ここからディレクトリのデータを読み込んだり、ファイルとフォルダが混合された状態でドロップされた場合の処理を記述することができます。
ドラッグ&ドロップでフォルダを扱うサンプルコード
用語をつらつらと解説していてもわかりにくいので、この辺りでサンプルコードを載せておきます。
尚、ブラウザでフォルダをドロップするなど一部の処理はローカルでサーバーを立てて動作確認する必要があるため、Express.jsなどを使って適当にサーバーを立てていただければと思います。
ご参考まで、Express.jsを使う時のテンプレートを生成してくれるCLIツールを貼っておきます。
express-generator
https://expressjs.com/ja/starter/generator.html
では、以下、サンプルコードです。
※型を書かないと分かりづらかったので、スクリプトはTypeScriptで記載しています。 実際に動かす際はtscなどでJavaScriptに変換してください。
動かし方の例としては、 npx tsc --init
でtsconfig.jsonを追加し tsc --watch
でファイル保存時にコンパイルできます。
package.jsonの scripts
に tsc --watch
を実行する設定を追加する等で対応してください。
※サンプルコードはExpress.jsで動かすことを想定し、HTMLを記載したファイルは index.ejs
としました。
index.ejs
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Directory Drag Drop</title> <style> body { margin: 0; padding: 0; font-family: Arial, sans-serif; } #wrapper { position: relative; width: 100vw; height: 100vh; padding: 10px; box-sizing: border-box; } #dropzone { text-align: center; width: 100%; height: 300px; background-color: #ddd; box-sizing: border-box; display: flex; align-items: center; justify-content: center; } #boxtitle { font-size: 32px; vertical-align: middle; text-align: center; color: black; } </style> </head> <body> <div id="wrapper"> <div id="dropzone"> <div id="boxtitle"> フォルダ・ファイルをここにドロップ(混合・複数可) </div> </div> </div> <script src="app.js"></script> </body> </html>
app.ts
// FileSystemDirectoryReader を使ってディレクトリ配下のentryを読み込む const getEntriesFromDirectoryReader = (reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> => { return new Promise((resolve, reject) => { reader.readEntries((entries) => { resolve(entries) }, (error) => reject(error)) }) } // FileSystemDirectoryEntry 配下のentryを取得 const getFileEntriesFromDirectoryEntry = async (entry: FileSystemDirectoryEntry): Promise<FileSystemEntry[]> => { const reader = entry.createReader() const iterationAttempts: FileSystemEntry[] = [] // Chromeの場合、readEntriesで一度に100件までしか取得できないため、取得がなくなるまでループ for(;;){ const entriesCurrent = await getEntriesFromDirectoryReader(reader) if (entriesCurrent != null && entriesCurrent.length > 0) { entriesCurrent.forEach((item) => { iterationAttempts.push(item) }) } else { break } } return iterationAttempts } // FileSystemFileEntry を File インスタンスに変換 const convertFileEntryToFile = (entry: FileSystemFileEntry): Promise<File> => { return new Promise((resolve, reject) => { try { entry.file(file => { resolve(file) }) } catch(e) { reject(e) } }) } // ディレクトリ情報をネストとともに格納する際のデータ型 interface DirectoryData { level: number name: string children: (File | DirectoryData)[] } // entryが1つ渡ってくるものとして、entryを再帰的に処理(複数ドロップされた場合はこの関数を個数分実行) const traverseEntry = async (entry: FileSystemEntry, level: number = 0) => { if(entry.isFile){ const fileObj = await convertFileEntryToFile(entry as FileSystemFileEntry) return fileObj } else if (entry.isDirectory) { const currentDirectoryData: DirectoryData = { level: level, name: entry.name, children: [] } const entries = await getFileEntriesFromDirectoryEntry(entry as FileSystemDirectoryEntry) currentDirectoryData.children = await Promise.all(entries.map(currentEntry => { if(currentEntry.isFile){ return convertFileEntryToFile(currentEntry as FileSystemFileEntry) } else { return traverseEntry(currentEntry, level + 1) } })) return currentDirectoryData } else { throw new Error("entry is not file or directory") } } window.onload = () => { const dropzoneElement = document.getElementById("dropzone") as HTMLDivElement dropzoneElement.addEventListener("dragenter", (event) => { console.log("#onDragEnter") console.log("event", event) // ブラウザ上でのドラッグ開始時に表示を変えたい場合などは、ここに処理を記述 }) dropzoneElement.addEventListener("dragleave", (event) => { console.log("#onDragLeave") console.log("event", event) // ブラウザ上でのドラッグ終了時に表示を変えたい場合などは、ここに処理を記述 }) dropzoneElement.addEventListener("drop", async (event) => { console.log("#onDrop") console.log("event", event) const dataTransfer = event.dataTransfer if(dataTransfer == null){ return } // DataTransferの中身を比較 const items = dataTransfer.items console.log("items", items) const files = dataTransfer.files console.log("files", files) event.preventDefault() // ドロップされた項目をブラウザの別タブで開かないようにする // DataTransferItemListをentryに変換 const entries = Array.from(items).map(item => item.webkitGetAsEntry()).filter(item => item != null) console.log(entries) // entry一覧をFileとネスト情報付きのオブジェクトに変換 const results = await Promise.all(entries.map(entry => traverseEntry(entry))) console.log(results) }) }
上記のサンプルコードでは、ファイルやフォルダが要素内にドロップされた際に内容を File インスタンスまたはディレクトリ情報格納用のオブジェクトに変換してconsole.logしています。
File インスタンスとして格納されたものはデータを読み取ってブラウザ側で処理できます(複数ドロップにも対応しています)。
サンプルコードのうち、 interface DirectoryData
で記述したオブジェクトにはフォルダのネストを格納しており、実際のJavaScript上の処理で再帰的に処理させる場合にはネストのレベルに制限を入れないと延々と処理が続いてしまいます。
例えばフォルダ作成機能を持つストレージサービスを作る場合、ネストのレベルに上限を設けることでリクエストが延々と走るのを防げます。
ちなみにGoogle Driveではネストのレベルの上限が100となっています。
上記したサンプルコードではドロップされた内容をconsole.logするだけですが、ここにfetchでリクエストを飛ばしたりファイル内容を読み込んで加工する処理を付け加えたりすることでさまざまな機能を実現できます。
実際に機能を作る場合の観点
サンプルコードを参考に処理を付け加えてアプリを作る際は、以下の点についてメリット・デメリットを考えて設計してみると良いかと思います。
- ファイルを加工・変換する場合
- 処理をブラウザ側で行うのか?サーバー側で行うのか?
- 大量のファイルがアップロードされる場合の処理
- どれぐらいの人数が、どれぐらいのファイル数をアップロードするのか?
- ドロップされたファイルをアップロードしてサーバー側で処理する場合
- 処理のトリガー
- 非同期で処理させる方法
- 処理状況を取得する方法
- ファイルの加工・変換が失敗した場合の処理
上記は技術的な観点の例です。
実際の機能追加では他にも実装工数、コスト、競合サービスでの実装状況などさまざまな観点で考える必要があります。
競合サービスを触っておくと実例を学べますし消費体験によって引き出しが増えていくので、時間を見つけて既存のサービスを触るようにしたいですね。
まとめ
2回にわたってフォルダアップロードをテーマにフロント側の技術について解説してきましたが、サンプルを載せたのはあくまでもごく基本的な部分になります。
サンプルコードを改変することでさまざまな機能を実現できますので、ぜひご自身で処理を付け加えてユニークなアプリを作ってください。
フロント側・サーバー側ともに非常に勉強になると思います。