記事一覧に戻る

Javascriptでdrag-and-dropを実装する方法

はじめに

Drag-and-Dropについて

ファイルをアップロードする時などに、ドラッグ&ドロップに対応していると便利ですよね。PCの操作でも、ドラッグ&ドロップはファイルを別のフォルダに移す時に使う操作です。マウス誕生の時からある操作方法で、その歴史は長いようです。そのため、WEBサービスへのアップロードも、ドラッグ&ドロップで行うほうが、より直感的な操作だと言えると思います。

OneDriveやGoogle Drive、DropBoxといった主要なクラウドサービスでも、ファイル選択によるアップロードと、ドラッグ&ドロップによるアップロードの両方に対応しています。 使う側としても、スマホの場合、PCの場合で使い分けができるので、とても便利に感じます。UIの幅を広げるにも、ドラッグ&ドロップの実装の仕方を覚えておくと良いのではないでしょうか!?

動機

MD-ConverterというMDファイルをHTMLに変換するWebページを作りました。この時にはじめてドラッグ&ドロップを実装したのですが、dragイベントの 制御に苦戦したので、整理もかねて記事にしていきます。

注意点

ドラッグ&ドロップは確かに便利ですが、マウスやトラックパッドのようなポインティング・デバイスの利用を前提としているため、デメリットもあります。

  • スマートフォンのようなモバイルデバイスで正常に動作しない
  • アクセシビリティに課題(マウス操作が難しい人もいる)

マウスが使えないデバイスや、マウスの利用が制限される人も考慮して、ファイル選択方式と一緒に実装し、アップロード方法の選択肢の1つとして提供するのが良いと思います。

対象の方

HTML,CSS,Javascriptの基本的な知識がある方向けに記載しています。Javascriptは、for...ofループや、基本的なDOM操作ができる方であれば問題ないと思います。

Dragイベントについて

clickmousemoveといったマウス操作と同じく、ドラッグ操作にもイベントがあります。種類がたくさんあるので、ここでは今回使うもののみ記載します。

イベントの種類

イベントが発動することを発火と表現しています。いずれも、ドロップ先の要素に設定するイベントとなります。

  • dragenter:ドロップ先に入った時に発火
  • dragover :ドラッグしている時に発火
  • dragleave:ドロップ先から出た時に発火
  • drop :ドロップした時に発火

Dragイベントオブジェクト

上記イベントのコールバック関数の引数には、Dragイベントオブジェクトが設定されます。 イベントのdataTransferプロパティに、ドラッグしているデータの情報が格納されています。

ファイル情報も、dataTransferオブジェクトから抽出します。方法は2つあります。

型が違うので注意が必要です。itemsは、ファイル以外のデータも格納可能で、ファイルのみに対応しているfilesより新しい機能になります。

filesは、名前のとおり、File型データのリストとなります。

itemsは、DataTransferItem型のリストです。DataTransferItemのgetAsFile()メソッドを呼べば、File型のデータを取得できます(ファイルでない場合はnullになります)。

いずれの型も、JSのArrayではなく、Array-Likeなオブジェクトです。つまり、リストを走査する場合、for...ofでループさせるか、Array.from()でArrayに変換する必要があります。

ファイルのみを扱うのであれば、どちらを使っても動作に違いはありません。古いブラウザを考慮するのであれば、filesを使えば問題無いと思います。

以降の例示では、いずれの型にも対応させるために、itemsが無い場合は、filesを使うように記載していきます。

// drop時のcallback。
// eventにはDragイベントが渡されます。
function drop(event) {
    // デフォルトのdrop動作は無効化します。
    event.preventDefault();

    if (event.dataTransfer.items) {
        // itemsがある場合の処理
    } else if (event.dataTransfer.files) {
        // itemsが無く、filesがある場合の処理
    }
}

実装例

ドラッグ&ドロップしたファイルは、実際のアプリケーションではサーバにアップロードすることが多いと思います。そこまで盛り込むと、ファイルをFormDataに追加したり、fetch等でPOSTしたり、サーバ側の処理を記載したり、、、ドラッグ&ドロップ以外の情報が多くなってしまうため、今回は「ドラッグ&ドロップしたファイルからダウンロードリンクを生成する」例示にします。

リンクはURL.createObjectURLを使って作成します。この場合、不要になった時はメモリ解放のためにURL.revokeObjectURLで削除するのがグッド・プラクティスとされています。今回は、ドラッグ&ドロップの実装とは直接が関係ないため、リンクの削除処理は省略します。ご了承ください。

素のJavascript

Reactのようなフレームワークを使わず、Javascriptだけで実装してみます。

HTML

drop-targetのクラス名の要素が名前のとおり、ドロップ先になります。ここにJavascriptでドロップ系のイベントを追加していきます。

ドロップ後のリンクは、動的にJavascriptで作成するので、HTMLにはありません。リンクの追加先は、link-containerのクラス名の要素です。

<!DOCTYPE html>
<html lang="js">

<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">
    <link rel="stylesheet" href="./main.css">
    <title>ドラッグアンドドロップ</title>
</head>

<body>
    <script src="./main.js"></script>
    <div class="drop-target">
        <div class="message">ここにドラッグ&ドロップしてください!</div>
    </div>
    <div class="link-container"></div>
</body>

</html>

CSS

hoverクラスはターゲット要素の上をドラッグしている時、背景色を変えるために使います。

また、ターゲット要素の子要素の上にドラッグした時、dragenterやdragleaveが発動して背景色の切替が行われてしまうため、ターゲット要素の子要素全てのpointer-eventを無効化しています。

.drop-target {
    width: 500px;
    height: 500px;
    border: solid 1px black;
    margin: auto;
    display: flex;
    justify-content: center;
    align-items: center;
}

/* 子要素の上にドラッグされたときに
   dragenter,dragleaveが発火するのを防ぐ
*/
.drop-target * {
    pointer-events: none;
}

/* ドラッグされている時に背景変える */
.hover {
    background-color: rgba(30, 30, 30, 0.3);
}

.download-link {
    display: block;
    text-align: center;
}

Javascript

.drop-targetにドラッグ系のイベントを追加していきます。主な処理はdrop時の処理となります。drop時に、dragイベントオブジェクトからファイルを取得し、そこからリンクを生成していきます。

dragoverイベントですが、event.preventDefault()でデフォルトの動作を無効化する必要があります。そうしないと、デフォルトの動作(ドロップしたファイルをブラウザで開く)が邪魔してdropイベントのカスタムができません。

dragenterとdragleaveは、ターゲットにドラッグしているときに背景色を変更するために定義しています。具体的には、dragenter時にhoverクラスを追加して背景色を変え、dragleave時にクラスを削除して元の色に戻しています。

/**
 * fileからダウンロードリンクを作成し、
 * parentに子要素として追加
 * @param {File} file
 * @param {HTMLElement} parent 
 */
function downloadLink(file, parent) {
    const child = document.createElement("a")
    child.className = "download-link"
    // ダウンロードした時のファイル名
    child.download = file.name
    // File型からURLを作成
    child.href = URL.createObjectURL(file)
    // aタグに表示するファイル名を表示
    child.innerText = file.name
    // 親要素にアペンド
    parent.appendChild(child)
}

/**
 * dragoverのコールバック。
 * 対象要素の上をdragしているとき発火。
 * @param {DragEvent} event 
 */
function dragover(event) {
    // dropをカスタムするために、要無効化
    event.preventDefault()
}

/**
 * dragenterのコールバック
 * 対象要素に入った時に発火
 * @param {DragEvent} event 
 */
function dragenter(event) {
    event.preventDefault()
    // 色を変えるcssを適用するため。
    event.target.classList.add("hover")
}

/**
 * dragleaveのコールバック
 * 対象要素からdragが外れたら発火
 * @param {DragEvent} event 
 */
function dragleave(event) {
    event.preventDefault()
    // 色を元に戻す
    event.target.classList.remove("hover")
}

/**
 * dropイベントのコールバック
 * @param {DragEvent} event 
 */
function drop(event) {
    // デフォルトのdrop動作は無効かします。
    event.preventDefault();
    // リンクを差し込む親要素
    const container = document.querySelector(".link-container")

    if (event.dataTransfer.items) {
        // itemsがある場合の処理
        // Array-Likeのためfor...ofでループ
        const items = event.dataTransfer.items;
        for (const item of items) {
            // DataTransferItemをFile型として取得
            const file = item.getAsFile();
            if (!file) continue;
            downloadLink(file, container);
        }

    } else if (event.dataTransfer.files) {
        // itemsが無く、filesがある場合の処理
        const files = event.dataTransfer.files;
        // Array-Likeのためfor...ofでループ
        for (const file of files) {
            downloadLink(file, container);
        }
    }
    // 背景色を戻すために呼ぶ
    dragleave(event)
}

function main() {
    // dropターゲットの要素を取得し、drag系イベントを設定
    const target = document.querySelector(".drop-target")
    target.addEventListener("dragenter", dragenter)
    target.addEventListener("dragover", dragover)
    target.addEventListener("dragleave", dragleave)
    target.addEventListener("drop", drop)
}

window.addEventListener("load", main)

試してみる

実際にHTMLを開いてファイルを2つドラッグ&ドロップしてみます。

ドラッグ

main.js,main.cssの2つをドラッグしています。背景色が変わっていて、dragenterが効いているのが分かります。

testing-drag

ドロップ

ドロップした2つのファイルがリンクになっています。dropも効いていますね。背景色も戻っていて、dragleaveも効いています。

testing-drop

リンクをクリック

リンクをクリックすればダウンロードされます。main.jsをクリックしたらちゃんとブラウザの下のほうにダウンロード情報が出てきました。

download

React.js

React.jsのコンポーネントとして同じものを実装してみます。cssは素のJavascriptと同じものを使います。Dragイベントからファイルを抽出するところはReactの場合でも同じです。

ターゲット要素の背景色を、Reactらしくstateに応じて切り替えています。ダウンロードリンクは、ドロップしたファイルの一覧をstateとして 管理し、このstateを元にanchorタグを描写しています。

import { useState } from "react";
import "./styles/main.css";

export default function DragAndDrop() {

  // 背景色を変えるため、ターゲット要素の上にドラッグしているかを判定
  const [isHover, setIsHover] = useState(false);

  // ダウンロード用リンク生成に使う。File型のリスト
  const [fileLinks, setFileLinks] = useState([]);

  // dragoverのコールバック
  const dragover = event => event.preventDefault();
  
  // dragenterのコールバック
  const dragenter = event => {
    event.preventDefault();
    setIsHover(true);
  };

  // dragleaveのコールバック
  const dragleave = event => {
    event.preventDefault();
    setIsHover(false);
  };

  // dropのコールバック
  const drop = event => {
    event.preventDefault();
    // dropされたファイルを格納する配列
    const links = [];

    if (event.dataTransfer.items) {
      const items = event.dataTransfer.items;
      for (const item of items) {
        const file = item.getAsFile();
        if (!file) continue;
        // Fileを配列に追加し、stateを更新
        links.push(file);
        setFileLinks(links);
      }
    } else if (event.dataTransfer.files) {
      const files = event.dataTransfer.files;
      for (const file of files) {
        // Fileを配列に追加し、fileLinkを更新
        links.push(file);
        setFileLinks(links);
      }
    }
    dragleave(event);
  };

  return (
    <>
      <div
        className={isHover ? "drop-target hover" : "drop-target"}
        onDragOver={dragover}
        onDragEnter={dragenter}
        onDragLeave={dragleave}
        onDrop={drop}
      >
        <div className="message">
          ここにドラッグ&ドロップしてください!
        </div>
      </div>
      <div className="link-container">
        {fileLinks.map((file, i) => {
          return (
            <a
              className="download-link"
              key={i}
              href={URL.createObjectURL(file)}
              download={file.name}
            >{file.name}</a>
          )
        })}
      </div>
    </>
  )
}

動きは素のJavascriptの場合と全く同じなので、画面のキャプチャは省略します。

最後に

ドラッグ&ドロップのUI部分の実装を、素のJavascriptとReact.jsの場合で行いました。

Dragイベントは種類が多く、ドロップ時の処理をカスタムするためには、dragoverのように、drop以外のイベントのデフォルト処理を無効化する必要があったり、 dataTransferオブジェクトの扱いのよく分からなかったり、初めて実装した時は、思っていたより苦戦してしまいました。

しかし、こうやって見ると案外シンプルですね。dataTransferオブジェクトも、Fileオブジェクトも、ブラウザの機能を使うためのAPIなので、事前にMDNで仕様を確認すればそんなに苦戦しなかったかもしれません。

ドラッグ&ドロップは、ファイル選択の方法の1つとして実装されていると便利だと思うので、私も今後活用していきたいと思います。

参考URL

参考にしたMDNのリンク達です。

記事一覧に戻る