はじめに
ユーザーが選択したファイルをアップロードする必要がある場合、ファイル選択フィールドを設置することはもちろんですが、ファイルをドラッグ&ドロップしてアップロードできるようにもなっていると使い勝手のいいサービスだと言えます。しかし、ドラッグ&ドロップできるエリアが一部分だけだと少し不便な場合があります。
本記事では、ウィンドウ内全体をドラッグ&ドロップできるエリアとしてファイルをアップロードする方法について説明します。
サンプル
埋め込んだCodePenだと正常に動作しないかもしれないので、その場合は右上の「EDIT ON CODEPEN」をクリックするかこちらをクリックしてください。
ウィンドウ内にファイルをドラッグ&ドロップしてください。ファイル形式はCSVのみで、他形式(画像など)をドラッグ&ドロップするとエラーが表示されます。サンプルなので実際にアップロードは行いませんが、動作イメージは伝わるかと思います。
実装
HTML
必要なのはファイルをドラッグしたときに表示するオーバーレイのみです。なお、本記事ではドラッグ&ドロップによるファイルアップロード処理のみを対象としているので、必要なら別途ファイル選択フィールドによるファイルアップロード処理を追加してください。
HTML
<div id="overlay">Drop CSV file here</div>
<div id="form">
<p>Please drag'n'drop a CSV file on the this window</p>
</form>
Slim
#overlay Drop CSV file here
#form
p Please drag'n'drop a CSV file on the this window
CSS
ファイルをドラッグしたときに表示するオーバーレイの初期表示は非表示にしておきます。
SCSS
#overlay {
display: flex;
justify-content: center;
align-items: center;
position: fixed;
z-index: 32;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
filter: opacity(0);
visibility: hidden;
color: #fff;
font-size: 4.75rem;
background-color: rgba(#000,.75);
transition: .25s;
}
#form {
#error {
color: tomato;
}
}
JavaScript
まずはJavaScript全文を掲載します。
JavaScript
function preventDefaults (e) {
e.preventDefault();
e.stopPropagation();
}
function enabled (e) {
this.overlay.style.filter = 'opacity(1)';
this.overlay.style.visibility = 'visible';
}
function disabled (e) {
this.overlay.style.filter = 'opacity(0)';
this.overlay.style.visibility = 'hidden';
}
function setErrorMessage (message) {
let messages = document.createElement('p');
messages.id = 'error';
messages.textContent = message;
const importFile = document.querySelector('#form');
if (importFile.lastChild) {
importFile.lastChild.remove();
}
importFile.appendChild(messages);
}
function getExtension(filename) {
var parts = filename.split('.');
return parts[parts.length - 1];
}
function handleDrop (e) {
// If a file type is csv
const ext = getExtension(e.dataTransfer.files[0].name);
if (ext.toLowerCase() != 'csv') {
e.preventDefault();
e.stopPropagation();
document.querySelector('#overlay').style.filter = 'opacity(0)';
document.querySelector('#overlay').style.visibility = 'hidden';
setErrorMessage('Invalid file type');
return false;
}
overlay.textContent = 'Now uploading the file...';
let formData = new FormData();
formData.append('file', e.dataTransfer.files[0]);
let xhr = new XMLHttpRequest();
xhr.onreadystatechange = () => {
if (xhr.readyState == 4) {
switch (xhr.status) {
case 200:
location.reload();
break;
case 500:
document.querySelector('#overlay').style.filter = 'opacity(0)';
document.querySelector('#overlay').style.visibility = 'hidden';
const errorMessage = JSON.parse(xhr.responseText)['alert'];
setErrorMessage(errorMessage);
break;
default:
break;
}
}
}
xhr.open('POST', '/upload');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
// Uncomment a bellow line when included this code to your app
// xhr.send(formData);
// Remove bellow 3 lines when included this code to your app
alert('Complete uploading');
document.querySelector('#overlay').style.filter = 'opacity(0)';
document.querySelector('#overlay').style.visibility = 'hidden';
}
/**
* @function acceptDragDrop
* @description Accept a file to upload on window with drag'n'drop
*/
const acceptDragDrop = () => {
const overlay = document.querySelector('#overlay');
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
overlay.addEventListener(eventName, preventDefaults, false);
});
window.addEventListener('dragenter', { handleEvent: enabled, target: overlay });
overlay.addEventListener('dragleave', { handleEvent: disabled, target: overlay });
overlay.addEventListener('drop', { handleEvent: handleDrop, target: overlay });
}
document.addEventListener('DOMContentLoaded', e => {
acceptDragDrop();
});
メインとなる関数はacceptDragDrop
です。
JavaScript
/**
* @function acceptDragDrop
* @description Accept a file to upload on window with drag'n'drop
*/
const acceptDragDrop = () => {
const overlay = document.querySelector('#overlay');
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
overlay.addEventListener(eventName, preventDefaults, false);
});
window.addEventListener('dragenter', { handleEvent: enabled, target: overlay });
overlay.addEventListener('dragleave', { handleEvent: disabled, target: overlay });
overlay.addEventListener('drop', { handleEvent: handleDrop, target: overlay });
}
ドラッグ&ドロップに関連するイベントは全部で8つあります。今回はdragenter
dragover
dragleave
drop
の4つを使用します。
イベント | 説明 |
---|---|
dragenter |
ドラッグされたファイルが要素内に入ったときに発生 |
dragover |
ドラッグされたファイルが要素内にあるときに発生 |
dragleave |
ドラッグされたファイルが要素内から出たときに発生 |
drop |
ドラッグされたファイルが要素内にドロップされたときに発生 |
各イベントのイベントリスナーを登録し、デフォルトの動作をキャンセルします。
JavaScript
function preventDefaults (e) {
e.preventDefault();
e.stopPropagation();
}
const acceptDragDrop = () => {
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
overlay.addEventListener(eventName, preventDefaults, false);
});
}
addEventListener
の第3引数は、イベントをDOMツリーの配下に配信するかを設定します。以下のページの lonesomeday という人の回答でとてもわかりやすく説明されています。
ドラッグイベント
ドラッグイベントでポイントとなるのはdragenter
のイベントリスナーをwindow
に登録していることです。これによりウィンドウ内全体でドラッグされたファイルを受け入れることができるようになります。反対にdragleave
のイベントリスナーはwindow
ではなくオーバーレイに登録します。
JavaScript
function enabled (e) {
this.overlay.style.filter = 'opacity(1)';
this.overlay.style.visibility = 'visible';
}
function disabled (e) {
this.overlay.style.filter = 'opacity(0)';
this.overlay.style.visibility = 'hidden';
}
const acceptDragDrop = () => {
window.addEventListener('dragenter', { handleEvent: enabled, target: overlay });
overlay.addEventListener('dragleave', { handleEvent: disabled, target: overlay });
}
addEventListener
の第2引数(登録する関数)に引数を渡したい場合、引数をJSON形式にし、handleEvent
に関数名、任意の変数名に値を設定します。
Element.addEventListener(eventName, { handleEvent: function, variable: value });
console.log(this.variable); // => value
ドロップイベント
ファイルがドロップされたら、ファイル形式(拡張子)のチェックを行い、問題なければXMLHttpRequest
を使ってファイルのアップロードを行います。
JavaScript
overlay.addEventListener('drop', { handleEvent: handleDrop, target: overlay });
function handleDrop (e) {
// If a file type is csv
const ext = getExtension(e.dataTransfer.files[0].name);
if (ext.toLowerCase() != 'csv') {
e.preventDefault();
e.stopPropagation();
document.querySelector('#overlay').style.filter = 'opacity(0)';
document.querySelector('#overlay').style.visibility = 'hidden';
setErrorMessage('Invalid file type');
return false;
}
overlay.textContent = 'Now uploading the file...';
let formData = new FormData();
formData.append('file', e.dataTransfer.files[0]);
let xhr = new XMLHttpRequest();
xhr.onreadystatechange = () => {
if (xhr.readyState == 4) {
switch (xhr.status) {
case 200:
location.reload();
break;
case 500:
document.querySelector('#overlay').style.filter = 'opacity(0)';
document.querySelector('#overlay').style.visibility = 'hidden';
const errorMessage = JSON.parse(xhr.responseText)['alert'];
setErrorMessage(errorMessage);
break;
default:
break;
}
}
}
xhr.open('POST', '/upload');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
// Uncomment a bellow line when included this code to your app
// xhr.send(formData);
// Remove bellow 3 lines when included this code to your app
alert('Complete uploading');
document.querySelector('#overlay').style.filter = 'opacity(0)';
document.querySelector('#overlay').style.visibility = 'hidden';
}
ファイル形式(拡張子)のチェック
ドロップされたファイルはe.dataTransfer.files[0]
で取得できます。ファイル名から拡張子を取得し、許可する拡張子(今回はcsv
)でない場合はエラーメッセージを表示します。
JavaScript
function getExtension(filename) {
var parts = filename.split('.');
return parts[parts.length - 1];
}
function setErrorMessage (message) {
let messages = document.createElement('p');
messages.id = 'error';
messages.textContent = message;
const form = document.querySelector('#form');
if (form.lastChild) {
form.lastChild.remove();
}
form.appendChild(messages);
}
function handleDrop (e) {
// If a file type is csv
const ext = getExtension(e.dataTransfer.files[0].name);
if (ext.toLowerCase() != 'csv') {
e.preventDefault();
e.stopPropagation();
document.querySelector('#overlay').style.filter = 'opacity(0)';
document.querySelector('#overlay').style.visibility = 'hidden';
setErrorMessage('Invalid file type');
return false;
}
}
ファイルのアップロード
onreadystatechange
はreadyState
の値が変化したときに発生します。readyState
が4
、つまりアップロードが完了し、ステータスが200
であればページを更新、500
であればエラーメッセージを表示します。
JavaScript
function handleDrop (e) {
let formData = new FormData();
formData.append('file', e.dataTransfer.files[0]);
let xhr = new XMLHttpRequest();
xhr.onreadystatechange = () => {
if (xhr.readyState == 4) {
switch (xhr.status) {
case 200:
location.reload();
break;
case 500:
document.querySelector('#overlay').style.filter = 'opacity(0)';
document.querySelector('#overlay').style.visibility = 'hidden';
const errorMessage = JSON.parse(xhr.responseText)['alert'];
setErrorMessage(errorMessage);
break;
default:
break;
}
}
}
}
HTTPメソッドとリクエストURLを設定してファイルをアップロードします。このとき、open()
を実行した後かつsend()
を実行する前に、HTTPヘッダーのX-Requested-With
にXMLHttpRequest
を設定するのを忘れないでください。理由は後述する「サーバー側の処理」で説明します。
JavaScript
function handleDrop (e) {
xhr.open('POST', '/upload');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
// Uncomment a bellow line when included this code to your app
// xhr.send(formData);
}
サーバー側の処理
今回は例としてRuby on Railsを使ったサーバー側の処理について説明します。
uploads_controller.rb
class UploadsController < ApplicationController
skip_forgery_protection only: :upload
def upload
begin
# 何らかの処理
rescue => e
if request.xhr?
# XMLHttpRequestを使用する通信 (Ajax)
render json: { alert: "An error occurred: #{e.class} #{e}" }, status: 500
else
# XMLHttpRequestを使用しない通信 (フォーム)
flash[:alert] = "An error occurred: #{e.class} #{e}"
redirect_to index_path
end
end
end
end
CSRFトークンの検証回避
Railsのフォーム送信処理ではCSRFトークンというものを通常のパラメーターと一緒に送信しています。CSRFトークンはレイアウトのヘッダー内に以下を記述すると自動生成されます。
application.html.erb
<%# 以下を記述 %>
<%= csrf_meta_tags %>
application.html.slim
/ 以下を記述
= csrf_meta_tags
<!-- ↓以下のHTMLが生成される -->
<meta name="csrf-param" content="authenticity_token">
<meta name="csrf-token" content="QBdjF1X8nJHciqKbAjDHW4hPu0+r8KxjCIB5W5mb6TNwBxdglu3+VvcgU8/6P/x6oZ...">
しかし、今回のようにJavaScriptのXMLHttpRequest
を使った通信の場合、CSRFトークンは送信されないため、Can't verify CSRF token authenticity.
というエラーが発生します。これを回避するために、通信を受け取るコントローラーに以下を追記します。
uploads_controller.rb
class UploadsController < ApplicationController
# 以下を追記
skip_forgery_protection only: :upload
end
これはupload
アクションに限りCSRFトークンの検証を回避するということです。当然、ファイル選択フィールドを含んだフォームから送信されたときもCSRFトークンの検証を行わなくなるのでセキュリティリスクは上がることになります。
XMLHttpRequest
を使った通信でもCSRFトークンの検証を正常に行うには、JavaScriptの処理でopen()
を実行した後かつsend()
を実行する前に、HTTPヘッダーのX-CSRF-Token
にCSRFトークンを設定します。
JavaScript
xhr.open('POST', '/upload');
// 以下を追記
const token = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
xhr.setRequestHeader('X-CSRF-Token', token);
xhr.send(formData);
例外処理
例外処理では、XMLHttpRequest
を使用する通信かそうでないかで処理を分ける必要があります。
uploads_controller.rb
class UploadsController < ApplicationController
def upload
begin
# 何らかの処理
rescue => e
if request.xhr?
# XMLHttpRequestを使用する通信 (Ajax)
render json: { alert: "An error occurred: #{e.class} #{e}" }, status: 500
else
# XMLHttpRequestを使用しない通信 (フォーム)
flash[:alert] = "An error occurred: #{e.class} #{e}"
redirect_to index_path
end
end
end
end
request.xhr?
を使うことでXMLHttpRequest
を使用する通信かそうでないかを判定できます。XMLHttpRequest
を使用する通信の場合、JSON形式でエラーメッセージを返却します。そうでない場合、flash
にエラーメッセージを設定します。
request.xhr?
はHTTPヘッダーのX-Requested-With
を見て判定しています。しかし、JavaScriptのXMLHttpRequest
オブジェクトはそのままだとX-Requested-With
に何も設定しないので、明示的にXMLHttpRequest
を設定する必要があります。
まとめ
ファイルをドラッグ&ドロップできるエリアが一部分だけの場合、その配置場所によってはファインダーやエクスプローラーが被ってしまい、ファインダーやエクスプローラーを少しずらすといったことが必要になります。地味なことですがストレスになります。
ファイルをドラッグ&ドロップできるエリアがウィンドウ内全体だとファインダーやエクスプローラーをずらす必要がないのでよりユーザビリティの高いデザインとなります。
本記事を参考にして、ファイルをドラッグ&ドロップしてアップロードする処理を実装していただければと思います。