🦊

フルスクラッチでドラッグ&ドロップ機能を実装する【React】

2025/03/10に公開

はじめに

自分が関わっている案件でGoogleカレンダーのようにブロック要素をドラッグ&ドロップやリサイズ操作できるアプリケーションの実装に関わったのですがそこでの知見などを共有できればと思います。

こういった機能を実現するライブラリとしてはdnd kit など、様々なものがありますが、ちょっとした特殊な操作・仕様に対応しようとした場合、ライブラリ機能だけでは実現できなかったり、ライブラリのドキュメントを深く掘っていく必要があったりで想定以上にいかないケースも多々あると思います。

そもそもドラッグ&ドロップをJS(React)でどうやって実現するのか、というところを自分なりにノウハウとして蓄積しておきたいという思いもあり、関わった案件ではフルスクラッチで実装しようと考えました。

一言でドラッグ&ドロップといっても実際にやってみると、色々な手順が必要になってきます。
とりあえず今回は基本的な部分の実装をステップバイステップでやっていければと思います。

ベースとなるBoxを作成する

まずは見た目として必要になるBoxをコンポーネントとして作成します。

App.tsx
import "./styles.css";

import { Box } from "./components/Box";

export default function App() {
  return (
    <div className="App">
      <Box label="BOX" x={10} y={10} width={100} height={100} />
    </div>
  );
}
Box.tsx
import React from "react";

type Props = {
  label: string;
  x: number;
  y: number;
  width: number;
  height: number;
};

export const Box: React.FC<Props> = ({ label, x, y, width, height }) => {
  return (
    <div
      className="Box"
      style={{
        top: `${y}px`,
        left: `${x}px`,
        width: `${width}px`,
        height: `${height}px`,
      }}
    >
      {label}
    </div>
  );
};
styles.css
.App {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  font-family: sans-serif;
  text-align: center;
}

.Box {
  position: absolute;
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  background-color: #000;
  color: #fff;
  user-select: none;
}

Boxの選択状態に応じたマウスカーソルの切り替えの実装

PCブラウザが前提になりますが、Boxを動かす際にマウスカーソルの表示を切り替えるよう調整を加えます。
なくてもドラッグ&ドロップの機能自体は実装できますが、やはりこういった見た目の変化があった方が操作感が伝わりやすいと思います。

Box.tsx
import React from "react";

type Props = {
+  id: number;
  label: string;
  x: number;
  y: number;
  width: number;
  height: number;
+  onMouseEnter: () => void;
+  onMouseLeave: () => void;
+  onMouseDown: (id: number) => void;
+  onMouseUp: () => void;
};

export const Box: React.FC<Props> = ({
+  id,
  label,
  x,
  y,
  width,
  height,
+   onMouseEnter,
+   onMouseLeave,
+   onMouseDown,
+   onMouseUp,
}) => {
  return (
    <div
      className="Box"
      style={{
        top: `${y}px`,
        left: `${x}px`,
        width: `${width}px`,
        height: `${height}px`,
      }}
+      onMouseEnter={onMouseEnter}
+      onMouseLeave={onMouseLeave}
+      onMouseDown={() => onMouseDown(id)}
+      onMouseUp={onMouseUp}
    >
      {label}
    </div>
  );
};

App.tsx
import "./styles.css";

import { Box } from "./components/Box";
import { useEffect, useState } from "react";

export default function App() {
+  const [selectedBoxId, setSelectedBoxId] = useState<number | null>(null);
+  const [hovered, setHovered] = useState(false);
+  const [cursor, setCursor] = useState<"auto" | "grab" | "grabbing">("auto");

+  useEffect(() => {
+    if (selectedBoxId === null) {
+      if (hovered) {
+        setCursor("grab");
+      } else {
+        setCursor("auto");
+      }
+    } else {
+      setCursor("grabbing");
+
+      const handleWindowMouseUp = () => {
+        setCursor("auto");
+        setSelectedBoxId(null);
+      };
+
+      window.addEventListener("mouseup", handleWindowMouseUp);
+
+      return () => {
+        window.removeEventListener("mouseup", handleWindowMouseUp);
+      };
+    }
+  }, [selectedBoxId, hovered]);
+
+  const handleBoxMouseEnter = () => {
+    setHovered(true);
+  };
+
+  const handleBoxMouseLeave = () => {
+    setHovered(false);
+  };
+
+  const handleBoxMouseDown = (id: number) => {
+    setSelectedBoxId(id);
+  };
+
+  const handleBoxMouseUp = () => {
+    setSelectedBoxId(null);
+  };

  return (
    <div
      className="App"
+      style={{
+        cursor,
+      }}
    >
      <Box
+        id={0}
        label="BOX"
        x={10}
        y={10}
        width={100}
        height={100}
+        onMouseEnter={handleBoxMouseEnter}
+        onMouseLeave={handleBoxMouseLeave}
+        onMouseDown={handleBoxMouseDown}
+        onMouseUp={handleBoxMouseUp}
      />
    </div>
  );
}

実際まだBoxは動かせませんが、なんとなく動かせる感がでてきました。

ポイントとしてはマウスカーソルの制御をBox側ではなくコンテナとなるApp.tsxで一元的に行っているところです。
また、window.addEventListener("mouseup", handleWindowMouseUp)window のマウスアップイベントでカーソルと選択状態をリセットするようにしています。
これが無いと、Boxからマウスカーソルが外れているのにgrabbingカーソルのままという気持ち悪い状態になってしまいます。

ドラッグ操作でBoxを動かす

いよいよBoxを動かしていきます。
window.addEventListener("mousemove", handleWindowMouseMove)を追加
してhandleWindowMouseMove内でBoxを動かす処理を実装していきます。

App.tsx
import "./styles.css";

import { Box } from "./components/Box";
import { useEffect, useState } from "react";

+type BoxType = {
+  id: number;
+  x: number;
+  y: number;
+  width: number;
+  height: number;
+};

export default function App() {
  const [selectedBoxId, setSelectedBoxId] = useState<number | null>(null);
  const [hovered, setHovered] = useState(false);
  const [cursor, setCursor] = useState<"auto" | "grab" | "grabbing">("auto");
+  const [mousePosition, setMousePosition] = useState<{
+    x: number;
+    y: number;
+  } | null>(null);
+  const [boxes, setBoxes] = useState<BoxType[]>([]);

+  useEffect(() => {
+    const box: BoxType = {
+      id: 0,
+      x: 10,
+      y: 10,
+      width: 100,
+      height: 100,
+    };
+    setBoxes([box]);
+  }, []);

  useEffect(() => {
    if (selectedBoxId === null) {
      if (hovered) {
        setCursor("grab");
      } else {
        setCursor("auto");
      }
    } else {
      setCursor("grabbing");

-      const handleWindowMouseUp = () => {
-        setCursor("auto");
-        setSelectedBoxId(null);
-      };

      window.addEventListener("mouseup", handleWindowMouseUp);

      return () => {
        window.removeEventListener("mouseup", handleWindowMouseUp);
      };
    }
  }, [selectedBoxId, hovered]);

+  useEffect(() => {
+    if (selectedBoxId === null) {
+      setMousePosition(null);
+    } else {
+      window.addEventListener("mousemove", handleWindowMouseMove);
+
+      return () => {
+        window.removeEventListener("mousemove", handleWindowMouseMove);
+      };
+    }
+  }, [selectedBoxId, boxes, mousePosition]);

  const handleBoxMouseEnter = () => {
    setHovered(true);
  };

  const handleBoxMouseLeave = () => {
    setHovered(false);
  };

  const handleBoxMouseDown = (id: number) => {
    setSelectedBoxId(id);
  };

  const handleBoxMouseUp = () => {
    setSelectedBoxId(null);
  };

  const handleWindowMouseUp = () => {
    setCursor("auto");
    setSelectedBoxId(null);
  };

+const handleWindowMouseMove = (event: MouseEvent) => {
+    if (selectedBoxId === null) {
+      return;
+    }
+
+    if (!mousePosition) {
+      setMousePosition({ x: event.clientX, y: event.clientY });
+      return;
+    }
+
+    const diffX = event.clientX - mousePosition.x;
+    const diffY = event.clientY - mousePosition.y;
+
+    setBoxes((prevBoxes) => {
+      return prevBoxes.map((box) => {
+        if (box.id === selectedBoxId) {
+          const newX = box.x + diffX;
+          const newY = box.y + diffY;
+          return { ...box, x: newX, y: newY };
+        }
+        return box;
+      });
+    });
+
+    setMousePosition({ x: event.clientX, y: event.clientY });
+  };

  return (
    <div
      className="App"
      style={{
        cursor,
      }}
    >
      {boxes.map((box: BoxType) => {
        return (
          <Box
            key={box.id}
            label="BOX"
+            {...box}
            onMouseEnter={handleBoxMouseEnter}
            onMouseLeave={handleBoxMouseLeave}
            onMouseDown={handleBoxMouseDown}
            onMouseUp={handleBoxMouseUp}
          />
        );
      })}
    </div>
  );
}

useStateでmousemoveごとのマウスポジションを保持して、
前回のフレームとイベント発火時のマウスポジションの差分を取り、その値をBoxに加算する、といった感じです。

ビューポートを設定して、その範囲内でBoxを動かす

とりあえずBoxは動かせるようになりましたが、そのままではBoxが画面の外に出てしまったりします。
次はビューポートを設定して、その範囲内でのみBoxを動かせるような制限を追加してみます。

App.tsx
import "./styles.css";

import { Box } from "./components/Box";
import { useEffect, useState } from "react";

type BoxType = {
  id: number;
  x: number;
  y: number;
  width: number;
  height: number;
};

+const VIEWPORT = {
+  width: 400,
+  height: 400,
+};

export default function App() {
  const [selectedBoxId, setSelectedBoxId] = useState<number | null>(null);
  const [hovered, setHovered] = useState(false);
  const [cursor, setCursor] = useState<"auto" | "grab" | "grabbing">("auto");
  const [mousePosition, setMousePosition] = useState<{
    x: number;
    y: number;
  } | null>(null);
  const [boxes, setBoxes] = useState<BoxType[]>([]);

  useEffect(() => {
    const box: BoxType = {
      id: 0,
-      x: 10,
+      x: 0,
-      y: 0,
+      y: 0,
      width: 100,
      height: 100,
    };
    setBoxes([box]);
  }, []);

  useEffect(() => {
    if (selectedBoxId === null) {
      if (hovered) {
        setCursor("grab");
      } else {
        setCursor("auto");
      }
    } else {
      setCursor("grabbing");

      window.addEventListener("mouseup", handleWindowMouseUp);

      return () => {
        window.removeEventListener("mouseup", handleWindowMouseUp);
      };
    }
  }, [selectedBoxId, hovered]);

  useEffect(() => {
    if (selectedBoxId === null) {
      setMousePosition(null);
    } else {
      window.addEventListener("mousemove", handleWindowMouseMove);

      return () => {
        window.removeEventListener("mousemove", handleWindowMouseMove);
      };
    }
  }, [selectedBoxId, boxes, mousePosition]);

  const handleBoxMouseEnter = () => {
    setHovered(true);
  };

  const handleBoxMouseLeave = () => {
    setHovered(false);
  };

  const handleBoxMouseDown = (id: number) => {
    setSelectedBoxId(id);
  };

  const handleBoxMouseUp = () => {
    setSelectedBoxId(null);
  };

  const handleWindowMouseUp = () => {
    setCursor("auto");
    setSelectedBoxId(null);
  };

const handleWindowMouseMove = (event: MouseEvent) => {
    if (selectedBoxId === null) {
      return;
    }

    if (!mousePosition) {
      setMousePosition({ x: event.clientX, y: event.clientY });
      return;
    }

    const diffX = event.clientX - mousePosition.x;
    const diffY = event.clientY - mousePosition.y;

    setBoxes((prevBoxes) => {
      return prevBoxes.map((box) => {
        if (box.id === selectedBoxId) {
-          const newX = box.x + diffX;
+          let newX = box.x + diffX;
-          const newY = box.y + diffY;
+          let newY = box.y + diffY;

+          // 境界チェック
+          newX = Math.max(0, Math.min(newX, VIEWPORT.width - box.width));
+          newY = Math.max(0, Math.min(newY, VIEWPORT.height - box.height));

          return { ...box, x: newX, y: newY };
        }
        return box;
      });
    });

    setMousePosition({ x: event.clientX, y: event.clientY });
  };

  return (
    <div
      className="App"
      style={{
        cursor,
      }}
    >
+      <div
+        className="Viewport"
+        style={{
+          width: `${VIEWPORT.width}px`,
+          height: `${VIEWPORT.height}px`,
+        }}
+      >
        {boxes.map((box: BoxType) => {
          return (
            <Box
              key={box.id}
              label="BOX"
              {...box}
              onMouseEnter={handleBoxMouseEnter}
              onMouseLeave={handleBoxMouseLeave}
              onMouseDown={handleBoxMouseDown}
              onMouseUp={handleBoxMouseUp}
            />
          );
        })}
+      </div>
    </div>
  );
}
styles.css
.App {
+  display: flex;
+  align-items: center;
+  justify-content: center;
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  font-family: sans-serif;
  text-align: center;
}

+ .Viewport {
+  position: absolute;
+  background-color: #eee;
+  border: solid 5px #ff0000;
+  box-sizing: content-box;
+}

.Box {
  position: absolute;
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  background-color: #000;
  color: #fff;
  user-select: none;
}

グリッドに沿ってBoxを動かす

だいぶ基本動作ができてきましたが、次に必要なものはなんでしょうか?
最初に言ったGoogoleカレンダーのようなアプリケーションを最終目標とした場合、
Boxはマウス座標に合わせてスムースに動くのではなく、特定のグリッド上にスナップして動くような動作が必要かと思います。

簡易的にですが、グリッドにスナップする機能を追加してみます。

App.tsx
import "./styles.css";

import { Box } from "./components/Box";
import { useEffect, useState, useRef } from "react";

type BoxType = {
  id: number;
  x: number;
  y: number;
  realX: number;
  realY: number;
  width: number;
  height: number;
};

const VIEWPORT = {
  width: 400,
  height: 400,
+  rows: 10,
+  cols: 10,
};

+ const STEP_X = VIEWPORT.width / VIEWPORT.cols;
+ const STEP_Y = VIEWPORT.height / VIEWPORT.rows;

export default function App() {
  const [selectedBoxId, setSelectedBoxId] = useState<number | null>(null);
  const [hovered, setHovered] = useState(false);
  const [cursor, setCursor] = useState<"auto" | "grab" | "grabbing">("auto");
  const [mousePosition, setMousePosition] = useState<{
    x: number;
    y: number;
  } | null>(null);
  const [boxes, setBoxes] = useState<BoxType[]>([]);

  useEffect(() => {
    const box: BoxType = {
      id: 0,
      x: 0,
      y: 0,
+      realX: 0,
+      realY: 0,
      width: 40,
      height: 40,
    };
    setBoxes([box]);
  }, []);

  useEffect(() => {
    if (selectedBoxId === null) {
      if (hovered) {
        setCursor("grab");
      } else {
        setCursor("auto");
      }
    } else {
      setCursor("grabbing");

      window.addEventListener("mouseup", handleWindowMouseUp);

      return () => {
        window.removeEventListener("mouseup", handleWindowMouseUp);
      };
    }
  }, [selectedBoxId, hovered]);

  useEffect(() => {
    if (selectedBoxId === null) {
      setMousePosition(null);
    } else {
      window.addEventListener("mousemove", handleWindowMouseMove);

      return () => {
        window.removeEventListener("mousemove", handleWindowMouseMove);
      };
    }
  }, [selectedBoxId, boxes, mousePosition]);

  const handleBoxMouseEnter = () => {
    setHovered(true);
  };

  const handleBoxMouseLeave = () => {
    setHovered(false);
  };

  const handleBoxMouseDown = (id: number) => {
    setSelectedBoxId(id);
  };

  const handleBoxMouseUp = () => {
    setSelectedBoxId(null);
  };

  const handleWindowMouseUp = () => {
    setCursor("auto");
    setSelectedBoxId(null);
  };

  const handleWindowMouseMove = (event: MouseEvent) => {
    if (selectedBoxId === null) {
      return;
    }

    if (!mousePosition) {
      setMousePosition({ x: event.clientX, y: event.clientY });
      return;
    }

    const diffX = event.clientX - mousePosition.x;
    const diffY = event.clientY - mousePosition.y;

    setBoxes((prevBoxes) => {
      return prevBoxes.map((box) => {
        if (box.id === selectedBoxId) {
+          let realX = box.realX + diffX;
+          let realY = box.realY + diffY;

+          // スナップ処理
+          let newX = Math.round(realX / STEP_X) * STEP_X;
+          let newY = Math.round(realY / STEP_Y) * STEP_Y;

          // 境界チェック
          newX = Math.max(0, Math.min(newX, VIEWPORT.width - box.width));
          newY = Math.max(0, Math.min(newY, VIEWPORT.height - box.height));

-          return { ...box, x: newX, y: newY };
+          return { ...box, x: newX, y: newY, realX, realY };
        }
        return box;
      });
    });

    setMousePosition({ x: event.clientX, y: event.clientY });
  };

  return (
    <div
      className="App"
      style={{
        cursor,
      }}
    >
      <div
        className="Viewport"
        style={{
          width: `${VIEWPORT.width}px`,
          height: `${VIEWPORT.height}px`,
        }}
      >
+        <div className="Grid">
+          {new Array(VIEWPORT.rows).fill({}).map((_, index) => {
+            return (
+              <div
+                key={index}
+                className="BorderH"
+                style={{
+                  top: `${index * STEP_Y}px`,
+                }}
+              />
+            );
+          })}
+          {new Array(VIEWPORT.cols).fill({}).map((_, index) => {
+            return (
+              <div
+                key={index}
+                className="BorderV"
+                style={{
+                  left: `${index * STEP_X}px`,
+                }}
+              />
+            );
+         })}
+        </div>
        {boxes.map((box: BoxType) => {
          return (
            <Box
              key={box.id}
              label="BOX"
              {...box}
              onMouseEnter={handleBoxMouseEnter}
              onMouseLeave={handleBoxMouseLeave}
              onMouseDown={handleBoxMouseDown}
              onMouseUp={handleBoxMouseUp}
            />
          );
        })}
      </div>
    </div>
  );
}

ポイントとしてはBoxにrealX realYといった保持情報を追加し、その値を使用して差分とスナップ値の座標を計算しているところです。
これを追加せずに BoxType.x BoxType.yのみでスナップ座標を計算してBoxに反映するとスナップ後のx``yがStateに保存され、次回にスナップ済の値で差分を計算してしまうのでうまく動作しなくなります。
realX realYには実際のマウス座標の値を保持し続けるようにします。

複数のBoxを配置してみる

必要最低限な機能としてはだいだいできましたが、最後に複数のBoxを配置してみます。

App.tsx
useEffect(() => {
-    const box: BoxType = {
-      id: 0,
-      x: 0,
-      y: 0,
-      width: 100,
-      height: 100,
-    };
-    setBoxes([box]);

+    let id = 0;
+    const boxes: BoxType[] = [];
+
+    for (let row = 0; row < 4; row++) {
+      for (let col = 0; col < 4; col++) {
+        const x = col * STEP_X * 2 + STEP_X;
+        const y = row * STEP_Y * 2 + STEP_Y;
+        const box: BoxType = {
+          id,
+          label: `box\n${id}`,
+          x,
+          y,
+          realX: x,
+          realY: y,
+          width: 40,
+          height: 40,
+        };
+
+        boxes.push(box);
+
+        id++;
+      }
+    }
+
+    setBoxes(boxes);
  }, []);
styles.css
.Box {
  position: absolute;
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  background-color: #000;
  color: #fff;
  user-select: none;
  font-size: 12px;
+  white-space: pre-line;
+  line-height: 1.2;
}

最後に

今回はドラッグ&ドロップのごく基本的な機能のみになりますが、やってみるとそれなりのコード量にはなってるかと思います。
自分でも実際やってみての感想ですが、これらを最初から一気に全部作ろうとすると、コードが煩雑になり、不具合あった際に問題の特定が難しくなったりしがちです。
多少遠回りになっても、どういった機能が必要かを一つ一つ整理して、段階的に検証を行いながら実装していくことが大切かなと思いました。

今回実装した他にはBoxのリサイズ機能やUndo機能、Boxを動かしたときに元の位置に半透明のBoxを表示する機能など、色々と追加すべきものが考えれれますが、その辺りは別の機会にできればと思います。

Discussion