zukucode
主にWEB関連の情報を技術メモとして発信しています。

React ドラッグ&ドロップでファイルを選択する

React ファイル選択のコンポーネントを作成するで、ファイル選択のコンポーネントを作成しました。

このコンポーネントに対して、ドラッグ&ドロップでファイルを選択できる機能を追加します。

ドラッグ時の挙動などのスタイルが未設定の状態ですが、最低限の機能は以下になります。

import { useCallback, useRef, useState } from 'react';

const App = () => {
  const [files, setFiles] = useState<File[]>([]);
  const attachRef = useRef<HTMLInputElement>(null);

  const handleInpuFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files == null) return;
    const files = Array.from(e.target.files);
    setFiles((current) => current.concat(files));
    if (attachRef.current) attachRef.current.value = '';
  }, []);

  const onDragEnter = useCallback((e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
  }, []);

  const onDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
  }, []);

  const onDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.stopPropagation();
  }, []);

  const onDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault(); // デフォルトのイベントをキャンセル
    e.stopPropagation(); // 親要素へのイベント伝播をキャンセル
    const files = Array.from(e.dataTransfer.items)
      .map((item) => item.getAsFile())
      .filter((file): file is File => file !== null); // ドロップしたファイルを配列で取得
    setFiles((current) => current.concat(files)); // stateに追加
  }, []);

  return (
    <>
      <div>
        {files.map((f) => (
          <div key={f.name}>{f.name}</div>
        ))}
      </div>

      <div>
        <input type="button" value="参照" onClick={() => attachRef.current?.click()}></input>
        <input type="file" style={{ display: 'none' }} ref={attachRef} multiple onChange={handleInpuFileChange}></input>
      </div>

      <div
        style={{ border: 'dashed 1px #000', padding: 20 }}
        onDragEnter={onDragEnter}
        onDragLeave={onDragLeave}
        onDragOver={onDragOver}
        onDrop={onDrop}
      >
        ここにファイルをドロップしてください。
      </div>
    </>
  );
};

export default App;

解説

以下のイベントを定義します。

onDragEnter
マウスカーソルが要素に入ったときに発生
onDranLeave
マウスカーソルが要素から外れた時に発生
onDragOver
マウスカーソルが要素内にあるときに発生
onDrop
ドロップしたときに発生

onDrop以外は特に処理はしていませんが、デフォルトのイベントをキャンセルするために設定しています。

例えばテキストファイルをドロップすると新しいタブでファイルが開かれてしまいますが、そのような動作を無効にします。

onDropイベントにて、e.dataTransfer.itemsでドロップしたオブジェクトを取得できます。

ファイルがドロップされたとは限らないので、getAsFileでファイルとして扱えるもののみ追加しています。

スタイルの追加

現状ではドラッグしたときにスタイルが変わらないため、どこにドロップすればいいのかがわかりずらいです。

また要素外にドロップするとデフォルトのイベント(テキストファイルの場合は新しいタブでファイルが開かれる)が発生してしまいます。

以下のように要素がドラッグ中かどうかの状態を管理して、要素内にドラッグしたら要素の背景色を変更します。

import { useCallback, useRef, useState } from 'react';

const App = () => {
  const [files, setFiles] = useState<File[]>([]);
  const attachRef = useRef<HTMLInputElement>(null);
  const [dragging, setDragging] = useState<number>(0);

  const handleInpuFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files == null) return;
    const files = Array.from(e.target.files);
    setFiles((current) => current.concat(files));
    if (attachRef.current) attachRef.current.value = ''; // 内部的なファイルの選択状態をクリア
  }, []);

  const onDragEnter = useCallback((e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    setDragging((current) => current + 1);
  }, []);

  const onDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    setDragging((current) => current - 1);
  }, []);

  const onDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.stopPropagation();
  }, []);

  const onDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.stopPropagation();
    const files = Array.from(e.dataTransfer.items)
      .map((item) => item.getAsFile())
      .filter((file): file is File => file !== null);
    setFiles((current) => current.concat(files));
  }, []);

  return (
    <>
      <div>
        {files.map((f) => (
          <div key={f.name}>{f.name}</div>
        ))}
      </div>

      <div>
        <input type="button" value="参照" onClick={() => attachRef.current?.click()}></input>
        <input type="file" style={{ display: 'none' }} ref={attachRef} multiple onChange={handleInpuFileChange}></input>
      </div>

      <div
        style={{ border: 'dashed 1px #000', padding: 20 }}
        style={{ border: 'dashed 1px #000', padding: 20, backgroundColor: dragging > 0 ? '#ccc' : undefined }}
        onDragEnter={onDragEnter}
        onDragLeave={onDragLeave}
        onDragOver={onDragOver}
        onDrop={onDrop}
      >
        ここにファイルをドロップしてください。
      </div>
    </>
  );
};

export default App;

draggingという変数を定義し、要素内に入ったら+1、外れたら-1をして、1以上の場合は要素内にいるという判定になります。

onDragEnteronDragLeaveは要素内の子要素に入った場合も発生してしまうため、bool値での管理ではなく数値で管理をしています。

次に、ドラッグ可能かどうかをカーソルで判断できるようにします。

まずは要素外にドロップを不可とするため、ルート要素などで以下を定義します。

import { useEffect } from 'react';

const onDragEnter = (e: DragEvent) => {
  e.preventDefault();
};

const onDragOver = (e: DragEvent) => {
  if (e.dataTransfer !== null) {
    // ドロップ不可のカーソル
    e.dataTransfer.dropEffect = 'none';
  }
  e.preventDefault();
};

const onDrop = (e: DragEvent) => {
  // デフォルトのイベントキャンセル
  e.preventDefault();
};

const Main = () => {
  useEffect(() => {
    window.addEventListener('dragenter', onDragEnter);
    window.addEventListener('dragover', onDragOver);
    window.addEventListener('drop', onDrop);

    return () => {
      window.removeEventListener('dragenter', onDragEnter);
      window.removeEventListener('dragover', onDragOver);
      window.removeEventListener('drop', onDrop);
    };
  }, []);

  return (
    <App />
  );
};

export default Main;

次にonDragOverのイベントで、ドロップ可能のカーソルを設定します。

const onDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
  e.preventDefault();
  e.stopPropagation();
  e.dataTransfer.dropEffect = 'copy';
}, []);

こうすることにより、要素外のドロップを無効にし、要素へのドロップが可能なのを視覚的に表現することができます。

React クリップボードにコピーしたファイルを貼り付けるで、クリップボードにコピーしたファイルを貼り付けて、選択する方法を紹介しています。


関連記事