ドラッグ・アンド・ドロップの仕組みと実装方法

ここでは、Web アプリケーションでのドラッグ・アンド・ドロップ (Drag and Drop) を実装方法について説明します。

尚、Android / Chrome 上ではこのページのコードでは動作確認できていません (Chrome 97 現在)。要件の確認時には注意してください。

HTML 上のドラッグ・アンド・ドロップの概要

ドラッグド・アイテムとドロップターゲット

ドラッグ・アンド・ドロップを考えるときの、主な役割は次の通りです。

ドラッグされる側の対象物をドラッグド・アイテム (dragged item) といいます。 ドラッグド・アイテムは HTML 要素である場合や、ファイルである場合があります。

ドラッグドアイテムとドロップターゲット

ドラッグド・アイテムをドロップする場所は、ドロップ・ターゲット (drop target) といいます。 今回の場合、ドロップ・ターゲットは HTML 要素になります。

HTML 要素をドラッグド・アイテムにするには、HTML 要素の draggable 属性を true にします。

ファイルをドラッグするのは、HTML や JavaScript では特に何もする必要はありません。Windows ならエクスプローラ、Mac ならファインダといった、ファイルマネージャからファイルをドラッグすれば良いだけです。

HTML 要素をドロップ・ターゲットにするには、 drop イベントを処理します。

ドラッグ・アンド・ドロップでは DataTransfer オブジェクトでデータを受け渡しする

drop イベントハンドラには DragEvent オブジェクトが渡されます。 DragEvent オブジェクトには dataTransfer プロパティがあり、 ここに DataTransfer オブジェクトがセットされています。

DataTransfer オブジェクトには、 items プロパティがあります。 items プロパティには、 DataTransferItemList オブジェクトがセットされており、このリストはその名の通り DataTransferItem のリストです。

DataTransferItem オブジェクトに、ドロップされたモノの情報が詰め込まれています。

ただし、HTML要素をドロップした場合、 drop イベントは発生するものの、デフォルトでは items には何も値が入りません。 ドロップする前に、 DataTransfer オブジェクトを使って、ドロップするモノをセットしておく必要があります。

HTML 要素のドラッグアンドドロップ

それでは、HTML要素のドラッグ&ドロップの、具体的な実装方法を見てみましょう。

まず、HTML要素をドラッグ&ドロップして、ドラッグ元の文字をドロップターゲットにセットしてみましょう。動作例は次のようになります。

まず、HTML は次のようになります。次の内容を test1.html とします。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Drag and Drop (Test 1)</title>
    <style>
      body {
        font-family: sans-serif;
      }

      #drop-target {
        border: 2px dotted darkgoldenrod;
        width: 200px;
        height: 30px;
        padding: 1em;
        font-size: 18px;
        overflow: scroll;
      }

      .drop-target-over {
        background: rgba(156, 167, 96, 0.1);
      }

      .item-container {
        width: 150px;
        padding: 0;
      }

      .dragged-item {
        display: block;
        margin-bottom: 5px;
        padding: 2px;
        color: black;
        background: white;
        font-size: 18px;
        border: 1px solid gray;
      }
    </style>
    <script src="test1.js"></script>
  </head>
  <body>
    <div style="display: flex; flex-direction: row; gap: 2em">
      <div class="item-container">
        <span id="item-1" class="dragged-item">Apple</span>
        <span id="item-2" class="dragged-item">Banana</span>
        <span id="item-3" class="dragged-item">Orange</span>
      </div>
      <div id="drop-target"></div>
    </div>
  </body>
</html>

HTML の li 要素として、Apple, Banana, Orange という文字を並べ、これらをドラッグできるようにします。 そして、ドロップターゲットにするための div 要素を用意しています。

このHTMLに取り込んで使うJavaScriptとして、次の内容を test1.js とします。

const $ = (id) => document.getElementById(id)

window.addEventListener('load', () => {
  const draggedItems = document.querySelectorAll('.dragged-item')
  for (const item of draggedItems) {
    item.draggable = true
    item.addEventListener('dragstart', (event) => {
      event.dataTransfer.setData('text/plain', event.target.id)
    })
  }

  $('drop-target').addEventListener('dragover', (event) => {
    event.preventDefault()
    event.dataTransfer.dropEffect = 'copy'
  })

  $('drop-target').addEventListener('drop', (event) => {
    let itemId = ''
    event.preventDefault()

    if (event.dataTransfer.items) {
      for (const item of event.dataTransfer.items) {
        const { kind, type } = item
        if (kind === 'file') {
          // Do nothing - item is file
        } else if (kind === 'string') {
          if (type === 'text/plain') {
            itemId = event.dataTransfer.getData(type)
          }
        }
      }
    }

    if (itemId !== '') {
      $('drop-target').innerHTML = $(itemId).innerHTML
    }
  })
})

このJavaScriptコードを見ていきましょう。

const $ = (id) => document.getElementById(id)

ここでは単に、 document.getElementById が何度も出現してながったらしいので、 $ に置き換えています。ただの好みの問題です。

window.addEventListener('load', () => {
  const draggedItems = document.querySelectorAll('.dragged-item')
  for (const item of draggedItems) {
    item.draggable = true
    item.addEventListener('dragstart', (event) => {
      event.dataTransfer.setData('text/plain', event.target.id)
    })
  }

window のload イベントで、各種要素にイベントハンドラを設定しています。

まずは、ドラッグド・アイテムとなる li 要素のコレクションを取ってきています。HTML側で li 要素に dragged-item クラスを取り付けています。

個々の要素は querySelectorAll() で取ってきたコレクションを、 for-of ループでそれぞれの要素を取り出しています。

for-in ループはオブジェクトの列挙可能なプロパティのループ。 for-of ループはイテラブルなオブジェクトのループになります。

forEach() でもいいです。好みの問題です。

6行目、li 要素の draggable プロパティを true とすることで、ドラッグ可能にしています

さらに、7行目で、 li 要素に dragstart イベントハンドラを設定しています。

dragstart イベントはドラッグアンドドロップ用のイベントです。イベントハンドラには DragEvent オブジェクトが渡されます。 そこで、 dataTransfer にある DataTransfer オブジェクトの setData メソッドを用いて、ドラッグ中のオブジェクトの情報をセットします。なんらかのデータをセットしないと、後の drop イベントで、DataTransfer オブジェクトからデータが取り出せません。

DataTransfer オブジェクトのsetData(format, data) メソッドは、二つの文字列の引数を受け取ります。

第一引数 format には、データタイプを記述します。通常は Mime タイプで指定します。つまり、単純な文字列をセットする場合は text/plain、 HTML データをセットするなら text/html です。

しかし、必ずしも標準的なMIMEタイプを指定する必要はなく、例えば単純な文字を二つセットしたい場合は setData('text/foo', 'hello')setData('text/bar', 'bye') などとセットすることで複数の値をセットできます。

ひとつのデータタイプにつき、ひとつのデータがセットできます。同じデータタイプで複数回、違うデータで setData() を呼び出した場合、最後にセットした値が採用されます。

さて、サンプルコードに戻ると、8行目ではドラッグを開始したDOM要素の id (文字列) を、 text/plain タイプのデータとして DataTransfer オブジェクトにセットしています。

setData の説明が長くなってしまいました。次へ行きましょう。

  $('drop-target').addEventListener('dragover', (event) => {
    event.preventDefault()
    event.dataTransfer.dropEffect = 'copy'
  })

こちらはドロップターゲットとなるDIV要素に dropover イベントハンドラを取り付けています。

ここではドラッグ操作を受け入れるために、 preventDefault() を呼びます。呼ばない場合ドラッグはキャンセルされたものとみなされます。

DataTransfer オブジェクトの dropEffect プロパティには、 copymovelinknone などの値がセットできます。 それぞれの環境に合わせて、ドラッグ中のカーソルが変わることで、ドラッグアンドドロップ操作で行われることを示します。

ここでは copy を指定しています。

この場合、それぞれの環境で次のようなカーソルで表示されます。

macOS / Chrome

iOS / Chrome

Windows / Chrome

さて、次がドロップ・ターゲットでドロップされたモノの処理を行う箇所です。

  $('drop-target').addEventListener('drop', (event) => {
    let itemId = ''
    event.preventDefault()

    if (event.dataTransfer.items) {
      for (const item of event.dataTransfer.items) {
        const { kind, type } = item
        if (kind === 'file') {
          // Do nothing - item is file
        } else if (kind === 'string') {
          if (type === 'text/plain') {
            itemId = event.dataTransfer.getData(type)
          }
        }
      }
    }

    if (itemId !== '') {
      $('drop-target').innerHTML = $(itemId).innerHTML
    }
  })
})

19行目の preventDefault() はデフォルトの処理を止めています。通常、ファイルをブラウザの画面にドロップすると、そのファイルを開いて表示することになります。preventDefault() によってファイルを開くという動作を抑制しています。

21行目から、event にセットされた DataTransfer オブジェクトの items プロパティをチェックしています。 items プロパティには、 DataTransferItemList がセットされていて、このコレクションから、DataTransferItem が取得できます。

DataTransferItem オブジェクトのkind プロパティには stringfile のいずれかの値が入ります。 file の場合は、 DataTransferItem オブジェクトから File オブジェクトが受け取れます。 string の場合、 DataTransfer オブジェクトにセットされた文字列が受け取れます。文字列は、 setData() メソッドの時にみたようにデータタイプ毎にひとつ取得できます。

今回は、 dragstart イベントで、データタイプ text/plain の文字列として、ドラッグド・アイテムの id をセットしていました。

そこで、items プロパティをチェックして、もし text/plain タイプの文字列があったら、それを id とみなして取得しています。

最後、34行目からはドロップされたDOM要素の innerHTML の値を、ドロップターゲットにセットしてドロップされた文字列を表示しています。

プログラムから明示的に DataTransfer オブジェクトにセットしたものは、文字列ひとつだけです。しかし実際のところ、ドロップされることが想定されていないファイルなどがドロップされる可能性もあります。この場合は、 DataTransfer オブジェクトから text/plain データタイプの文字列が取れません。 このような場合に備えて、 items プロパティのチェックを行なっています。そこまで慎重に考えないなら、ざっと DataTransfer オブジェクトが、 'text/plain' タイプのデータを保持していることを想定しても良いかもしれません。

HTMLにファイルをドラッグアンドドロップする場合

次に上記のプログラムを少し変更して、次のようにファイルをHTML要素にドロップして、そのファイル名を表示してみましょう。

HTML は同じものを使います。JavaScript の drop イベントハンドラだけを書き換えます。

  $('drop-target').addEventListener('drop', (event) => {
    let itemId = ''
    const droppedItems = []
    event.preventDefault()

    if (event.dataTransfer.items) {
      for (const item of event.dataTransfer.items) {
        const { kind, type } = item
        if (kind === 'file') {
          const file = item.getAsFile()
          droppedItems.push(file.name)
        } else if (kind === 'string') {
          if (type === 'text/plain') {
            itemId = event.dataTransfer.getData(type)
          }
        }
      }
    }

    if (itemId !== '') {
      droppedItems.push($(itemId).innerHTML)
    }

    if(droppedItems.length > 0){
      $('drop-target').innerHTML = droppedItems.join(', ')
    }

  })

19行目でドロップされたものを取り置く配列を用意しています。

22行目から DataTransfer オブジェクトの items プロパティをチェックします。 上では DataTransferItem の kind がファイルの場合は何もしていませんでしたが、今回は getAsFile() メソッドを使って、 DataTransferItem から File オブジェクトを取得しています。

File オブジェクトの name プロパティの値 (つまりファイル名) を、配列 droppedItemsに積んでいます。

40行目で配列 droppedItems に何か値が入っていたら、それを , でつなげてドロップ・ターゲットに表示しています。

ドラッグ・アンド・ドロップでファイルを選択して、ファイルをアップロードする方法

上では、ドラッグアンドドロップでファイルを選択して、File オブジェクトを取得する方法をみてきました。

これの応用例として、ファイルのドラッグアンドドロップに続けて、ファイルをサーバーにアップロードする方法と、具体的なコード例を簡単に紹介しておきます。

出来上がりは次のようになります。

基本的に「XMLHttpRequest でファイルをアップロードする方法」で紹介した方法を、少しだけ変更するだけです。

file 型の input でファイルを選択していた箇所を、ドラッグアンドドロップで選択するように変更しただけです。サーバー側のコードは全く同じです。

HTML は次のようになります。 test3.html とします。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Upload Test (multiple)</title>
    <!-- Bootstrap -->
    <link
      href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
      rel="stylesheet"
    />
    <!-- Bootstrap Icons -->
    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css"
    />
    <script src="test3.js"></script>
    <style>
      #dropTarget {
        width: 380px;
        min-height: 100px;
        border: 2px dotted rgba(135, 206, 250, 1.0);
      }

      #dropPrompt {
        color: gray;
        margin: 1em;
        font-style: italic;
      }
    </style>
  </head>
  <body>
    <div class="container">
      <!-- Navigation -->
      <nav aria-label="breadcrumb">
        <ol class="breadcrumb">
          <li class="breadcrumb-item"><a href="/">Home</a></li>
          <li class="breadcrumb-item active" aria-current="page">
            Multiple File Upload
          </li>
        </ol>
      </nav>
      <!-- Form -->
      <form class="mt-3 mb-3">
        <div id="progressBarContainer" class="progress invisible mb-1">
          <div
            id="progressBar"
            class="progress-bar progress-bar-striped progress-bar-animated"
            role="progressbar"
          ></div>
        </div>
        <div>
          <button type="button" id="uploadButton" class="btn btn-primary">
            <i class="bi bi-upload"></i> Upload
          </button>
          <button type="button" id="clearButton" class="btn btn-secondary">
            <i class="bi bi-x"></i> Clear
          </button>
        </div>
      </form>
      <div id="dropTarget">
        <div id="dropPrompt">Please drop files here</div>
        <ul id="fileList" class="list-group"></ul>
      </div>
    </div>
  </body>
</html>

このHTMLから取り込まれる JavaScript test3.js は次のようになります。

'use strict'

const $ = (id) => document.getElementById(id)
const selectedFiles = []

window.addEventListener('load', () => {
  $('uploadButton').addEventListener('click', (evt) => {
    evt.preventDefault()

    if (selectedFiles.length === 0) {
      console.log('No file is selected.')
      return
    }

    const fd = new FormData()
    selectedFiles.forEach((f) => fd.append('file1', f, f.name))

    const xhr = new XMLHttpRequest()

    xhr.open('POST', '/test1')

    // Basic Events
    xhr.addEventListener('load', (evt) => {
      console.log('** xhr: load')
      const response = JSON.parse(xhr.responseText)
      console.log(response)
    })

    xhr.addEventListener('progress', (evt) => {
      console.log('** xhr: progress')
    })

    xhr.addEventListener('error', (evt) => {
      console.log('** xhr: error')
    })

    // Upload Events
    xhr.upload.addEventListener('loadstart', (evt) => {
      console.log('++ xhr.upload: loadstart')
      setProgressBar(0)
    })

    xhr.upload.addEventListener('progress', (evt) => {
      const percent = ((evt.loaded / evt.total) * 100).toFixed(1)
      console.log(`++ xhr.upload: progress ${percent}%`)
      setProgressBar(percent)
    })

    xhr.upload.addEventListener('abort', (evt) => {
      console.log('++ xhr.upload: abort (Upload aborted)')
    })

    xhr.upload.addEventListener('error', (evt) => {
      console.log('++ xhr.upload: error (Upload failed)')
    })

    xhr.upload.addEventListener('load', (evt) => {
      console.log('++ xhr.upload: load (Upload Completed Successfully)')
    })

    xhr.upload.addEventListener('timeout', (evt) => {
      console.log('++ xhr.upload: timeout')
    })

    xhr.upload.addEventListener('loadend', (evt) => {
      console.log('++ xhr.upload: loadend (Upload Finished)')
      setTimeout(() => clear(), 1e3)
    })

    xhr.send(fd)
  })

  $('clearButton').addEventListener('click', (evt) => {
    evt.preventDefault()
    clear()
  })

  $('dropTarget').addEventListener('dragover', (event) => {
    event.preventDefault()
  })

  $('dropTarget').addEventListener('drop', (event) => {
    event.preventDefault()
    if(event.dataTransfer.items) {
      for(const item of event.dataTransfer.items){
        const { kind } = item
        if(kind === 'file'){
          const file = item.getAsFile()
          selectedFiles.push(file)
        }
      }
      updateFileList()
    }
  })
})

const setProgressBar = (percent) => {
  if (percent < 0) {
    showProgressBar(false)
  } else {
    showProgressBar(true)
    $('progressBar').style.width = `${percent}%`
  }
}

const showProgressBar = (show) => {
  const c = $('progressBarContainer')
  if (show) {
    removeClass(c, 'invisible')
    addClass(c, 'visible')
  } else {
    removeClass(c, 'visible')
    addClass(c, 'invisible')
    $('progressBar').style.width = '0%'
  }
}

const removeClass = (elm, cls) => {
  if (elm.classList.contains(cls)) {
    elm.classList.remove(cls)
  }
}

const addClass = (elm, cls) => {
  if (!elm.classList.contains(cls)) {
    elm.classList.add(cls)
  }
}

const clear = () => {
  showProgressBar(false)
  selectedFiles.length = 0
  updateFileList()
}

const updateFileList = () => {
  const fl = $('fileList')
  fl.innerHTML = '' // remove all children
  for (const f of selectedFiles) {
    const li = document.createElement('li')
    li.innerHTML = f.name
    li.className = 'list-group-item'
    fl.appendChild(li)
  }
}

id のネーミングコンベンションが、他のサンプルと違ってしまっていますが、前の記事「XMLHttpRequest でファイルをアップロードする方法」のページの方法を踏襲してそのままにしています。今後、HTML/CSS側は id も含めて、キャメルケースからケバブケースにする予定です。

以上、ここではドラッグ&ドロップを使った、HTML 要素のドラッグと、HTML 要素やファイルをドロップしたときの処理方法について説明しました。

ここまでお読みいただき、誠にありがとうございます。SNS 等でこの記事をシェアしていただけますと、大変励みになります。どうぞよろしくお願いします。

© 2024 JavaScript 入門