🙆

画像のドラッグ&ドロップをライブラリ使わずに実装する

2023/01/03に公開

Reactを使ったフロントエンド開発で画像のドラッグ&ドロップを実装することがあったので記事化してみます。

「React 画像 ドラッグアンドドロップ」で検索すると大体react-dropzoneというライブラリを使った実装方法が多く出てくると思います。これを使っても良いのですが、バンドルサイズを削りたい、せっかくならドラッグイベントについて勉強したい、ということでライブラリを使わずに実装しました。

https://react-dropzone.js.org/

出来上がりイメージ

以下のgifのような実装を目指します。


画像はいらすとや様より

開発してみた

大まかには以下のものを実装すればOKです。

  • ドロップする領域を作成し、そこに以下のドラッグ&ドロップイベントを登録する
    • dragenter: ドラッグしたものがそこに入ってきたときに何をするか?
      • 例:ドロップ領域のopacityを下げる
    • dragleave: ドラッグしたものがそこを出ていったときに何をするか?
      • 例:ドロップ領域のopacityを元に戻す
    • drop: ドラッグしているものをそこにドロップしたときに何をするか?
      • 例:画像をアップロードする
  • 画像がアップロードされたらその画像を表示する

各イベントの詳細は以下参照

https://developer.mozilla.org/ja/docs/Web/API/DragEvent

コードを書く

①ドロップする領域を作成

まずはドラッグ&ドロップができる領域として、DropImageZoneというコンポーネントを作ります。

src/DropImageZone.tsx
import { DragEvent, FC, ReactNode, useState } from "react";

type Props = {
  onDropFile: (file: File) => void;
  children: ReactNode;
};

const DropImageZone: FC<Props> = ({ onDropFile, children }) => {
  const [isDragActive, setIsDragActive] = useState<boolean>(false);

  const onDragEnter = (e: DragEvent<HTMLDivElement>) => {
    if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
      setIsDragActive(true);
    }
  };

  const onDragLeave = (e: DragEvent<HTMLDivElement>) => {
    setIsDragActive(false);
  };

  const onDrop = (e: DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    setIsDragActive(false);
    if (e.dataTransfer.files !== null && e.dataTransfer.files.length > 0) {
      if (e.dataTransfer.files.length === 1) {
        onDropFile(e.dataTransfer.files[0]);
      } else {
        alert("ファイルは1個まで!");
      }
      e.dataTransfer.clearData();
    }
  };

  return (
    <div
      onDragEnter={onDragEnter}
      onDragLeave={onDragLeave}
      onDragOver={(e) => e.preventDefault()}
      onDrop={onDrop}
      // mergeはclassを合成する自作の関数
      // Dragが有効のときopacityを変更する
      className={merge(style.drop_zone, isDragActive ? style.opacity : null)}
    >
      {children}
    </div>
  );
};

Reactのdiv要素にonDragEnter等のイベントを登録することができるので、それらを一つずつやっていくだけです。各イベントの引数の型はReact.DragEvent<HTMLDivElement>になります。

onDragEnterdragenterイベントに相当します。ここではドラッグ中かどうかを判定するstateであるisDragActiveを変更させます。画像を2個以上同時にアップロードすることは想定してないので、ファイルの数が1個でないときは無効なドラッグということでstateは変更させません。

onDragLeavedragleaveイベントに相当します。ここではonDragEnterで変更したstateを元に戻しています。

onDragOverdragoverイベントに相当します。ドラッグ中にするべきことは無いのですが、デフォルトではドラッグしているファイルを開いてしまうのでそれをpreventDefaultで阻止する必要があります。

onDropdropイベントに相当します。ここではアップロードされたファイルが1個だけかどうかを検証し、親コンポーネントから受け取ったonDropFileを実行します。onDropFileFile型の引数を受け取ります。

②画像アップロードしたあとの処理を記述

上記で画像のドロップができるようになったのであとは画像がアップロードされたらそれを表示するだけです。

src/index.tsx
import { FC, useState } from "react";
import Image from "next/image";

const RegisterCard: FC = () => {
  const [image, setImage] = useState<string | null>(null);

  const onDropFile = (file: File) => {
    if (file.type.substring(0, 5) !== "image") {
      alert("画像ファイルでないものはアップロードできません!");
    } else {
      const fileReader = new FileReader();
      fileReader.onload = () => {
        const imageSrc: string = fileReader.result as string;
        setImage(imageSrc);
      };
      fileReader.readAsDataURL(file);
    }
  };

  return (
    <div className={style.container}>
      {image ? (
        <Image
          src={image}
          width={403}
          height={598}
          alt="画像のプレビュー"
          className={style.preview_image}
        />
      ) : (
        <DropImageZone onDropFile={onDropFile}>
          <div className={style.content}>
            <Image
              src={"/file-upload.svg"}
              width={24}
              height={24}
              alt="アップロードアイコン"
            />
          </div>
        </DropImageZone>
      )}
    </div>
  );
};

画像をstateとしてそれがnullのとき(画像がまだアップロードされていないとき)はDropImageZoneを表示、画像がアップロードされたらその画像をそのまま表示します。

画像は最初File型になっているので、FileReaderで読み込んだものをアサーションでそのパスをstring型で受け取ります。読み込む際にFileのMIME型を見て画像かどうかをチェックしています(MIMEの最初がimageであれば画像ファイルと判定できる)。

https://developer.mozilla.org/ja/docs/Web/API/FileReader

おまけ: ファイル選択での画像アップロードも実装する

多くの場合、ドラッグ&ドロップだけでなくファイル選択でのアップロードもあわせて実装する必要があると思うので、そちらの実装も合わせて紹介します。

こちらの実装はドラッグ&ドロップよりも簡単で、シンプルにinput要素を使って実装するだけです。私はInputImageSelectorコンポーネントとして切り出してみました。

src/InputImageSelector.tsx
import { ChangeEvent, FC } from "react";

type Props = {
  onDropFile: (file: File) => void;
};

const InputImageSelector: FC<Props> = ({ onDropFile }) => {
  const onChange = (e: ChangeEvent<HTMLInputElement>) => {
    e.preventDefault();
    const files = e.target.files;
    if (files !== null && files.length > 0) {
      if (files.length === 1) {
        onDropFile(files[0]);
      } else {
        alert("ファイルは1個まで!");
      }
    }
  };

  return (
    <label htmlFor="image" className={style.input_image}>
      <input type="file" id="image" accept="image/*" onChange={onChange} />
      browse
    </label>
  );
};

input要素にonChangeイベントを登録します。また、画像のアップロードのみを想定しているため、acceptプロパティにimage/*を設定しています。

あとは先程のDropImageZoneと組み合わせるだけです。

src/index.tsx
...

return (
  <div className={style.container}>
    {image ? (
      <Image
        src={image}
        width={403}
        height={598}
        alt="画像のプレビュー"
        className={style.preview_image}
      />
    ) : (
      <DropImageZone onDropFile={onDropFile}>
        <div className={style.content}>
          <Image
            src={"/file-upload.svg"}
            width={24}
            height={24}
            alt="アップロードアイコン"
          />
        </div>
        <div className={style.message}>
          Drop files to upload or&nbsp;
          <InputImageSelector onDropFile={onDropFile} />
        </div>
      </DropImageZone>
    )}
  </div>
);
GitHubで編集を提案

Discussion