【CSS+JS】ウィンドウ内全体にファイルをドラッグ&ドロップしてアップロード

はじめに

ユーザーが選択したファイルをアップロードする必要がある場合、ファイル選択フィールドを設置することはもちろんですが、ファイルをドラッグ&ドロップしてアップロードできるようにもなっていると使い勝手のいいサービスだと言えます。しかし、ドラッグ&ドロップできるエリアが一部分だけだと少し不便な場合があります。

本記事では、ウィンドウ内全体をドラッグ&ドロップできるエリアとしてファイルをアップロードする方法について説明します。

サンプル

埋め込んだ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;
  }
}

ファイルのアップロード

onreadystatechangereadyStateの値が変化したときに発生します。readyState4、つまりアップロードが完了し、ステータスが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-WithXMLHttpRequestを設定するのを忘れないでください。理由は後述する「サーバー側の処理」で説明します。

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を設定する必要があります。

まとめ

ファイルをドラッグ&ドロップできるエリアが一部分だけの場合、その配置場所によってはファインダーやエクスプローラーが被ってしまい、ファインダーやエクスプローラーを少しずらすといったことが必要になります。地味なことですがストレスになります。

ファイルをドラッグ&ドロップできるエリアがウィンドウ内全体だとファインダーやエクスプローラーをずらす必要がないのでよりユーザビリティの高いデザインとなります。

本記事を参考にして、ファイルをドラッグ&ドロップしてアップロードする処理を実装していただければと思います。

関連記事

【CSS+JS】メニューアイコンの一種、ベントーメニューの実装方法(アニメーション付き)
# はじめに メニューアイコンの中ではハンバーガーメニューが有名だと思いますが、その他にもいろいろな種類があって、それぞれに名前もつけられています。 <a class="gallery" data-group="gallery" href= [...]
2021年10月18日 14:26
【CSS+JS】メインコンテンツの裏から現れるフッターの実装方法
# はじめに オシャレなサイトなどでたまに見かける「メインコンテンツの裏から現れるフッター」の実装方法について説明します。 # サンプル <iframe height="392" style="width: 100%;" scrollin [...]
2021年10月18日 13:26
【CSS+JS】背景画像の視差効果(パララックス)を実装する方法
# はじめに JavaScriptプラグインを使わずに、背景画像の視差効果(パララックス)をVanilla JS(ピュアなJavaScript)だけで実装する方法について説明します。 # サンプル まず、背景画像の視差効果(パララックス) [...]
2021年10月17日 12:08
【CSS+JS】モーダルウィンドウを表示しその中にYouTube動画を動的に埋め込む
# はじめに 動画のサムネイル画像をクリックしてモーダルウィンドウを表示し、その中にYouTube動画を動的に埋め込む方法について説明します。なお、本記事ではYouTube動画を動的に埋め込むまでを範囲とし、埋め込んだ動画の再生制御などは範囲外と [...]
2021年5月20日 23:23
【CSS+JS】テキストを1文字ずつ表示するアニメーションの実装方法
# はじめに モダンなサイトでよく見かける「テキストを1文字ずつ表示するアニメーション」の実装方法を解説します。 # 実装 ## サンプル 繰り返し動作を確認したい場合は右下の「Rerun」ボタンをクリックしてください。 <i [...]
2021年5月20日 13:26
【CSS】CSSだけでMarkdownのコードにファイル名をつける
# はじめに Markdownのコードに、そのコードがどのファイルのものなのかを示すためにファイル名が書いてあるとわかりやすいです。以下はQiitaの記事でコードを書いたときの一例です。 <img data-src="https://i.i [...]
2021年4月6日 14:39
【CSS】カーソルを乗せると流れるようなアニメーションのハンバーガーメニューを作る
# はじめに 今回はハンバーガーメニューにカーソルを乗せると川のように流れるアニメーションを作りたいと思います。 今回の記事は以下の記事をベースにしていますので、まだご覧になっていない方は先にこちらをご覧ください。 <div clas [...]
2020年8月31日 8:54
【CSS】JavaScriptを使わずにハンバーガーメニューを作る
# はじめに 当初はスペースの限られるスマートフォンなどの低解像度デバイス向けサイトにおいてスペースを有効活用するために登場したハンバーガーメニューですが、最近はPCなどの高解像度デバイス向けサイトでも使われているのをよく見かけます。 何よ [...]
2020年8月31日 7:34