Pragmatic drag and drop コトハジメ【List編】

この記事にたどり着いたあなたはこんなことを思っていたのではないでしょうか？
「PragmaticDnDの日本語解説無くないか!!?PragmaticDnDの解説無い!!!（伝われ）」
かく言う私もこのライブラリがプロダクトに採用され、DnD実装タスクにアサインされてから絶望したものです。
日本語記事一つもねえじゃんって。公式ドキュメントも物足りないって。弱いって。厳しいって。

そんな過去の私のように困っている方の助けになればいいなと思って書いた解説記事です。

# はじめに
当記事ではPragmaticDnDのExampleではじめに紹介されるListについてできるだけ詳らかに解説します。
ちょ〜〜〜っと長いですが、ぜひ最後までお付き合いください。

本記事で実装したコードはGitHubにも公開しているのでよければ参考にしてください。

https://github.com/Ahoxa/PragmaticDnDList

使用スタックは以下の通りです
- React: `^18.3.1`
- [Pragmatic drag and drop](https://atlassian.design/components/pragmatic-drag-and-drop/about "Pragmatic drag and drop"): `^1.4.0`
- Tailwind CSS: `^3.4.16`

# Get Started
さて、こんなリストを作ってみました。
まだDragで順番を変えることのできないUndraggableなこいつをDraggableにしていきましょう。

![スクリーンショット 2024-12-06 14.42.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125627/6ddefe67-673f-9ad4-6d01-6480dae112c2.png)

変更前のコードはこちらです。
```react:App.tsx
import { List } from "./List";
import { RiReactjsFill, RiVuejsFill, RiSvelteFill } from "react-icons/ri";

function App() {
  const array = [
    {
      id: "react",
      icon: <RiReactjsFill size={"22px"} />,
      title: "React",
      description: "React is developed by Jordan Walke.",
    },
    {
      id: "vue",
      icon: <RiVuejsFill size={"22px"} />,
      title: "Vue",
      description: "Vue is developed by Evan You.",
    },
    {
      id: "svelte",
      icon: <RiSvelteFill size={"22px"} />,
      title: "Svelte",
      description: "Svelte is developed by Rich Harris.",
    },
  ];

  return (
    <div className="w-1/2">
      <h1 className="font-bold text-4xl  w-full mb-2">Undraggable List</h1>
      <div className="flex flex-col gap-y-1">
        {array.map((item) => (
          <List
            key={item.id}
            id={item.id}
            icon={item.icon}
            title={item.title}
            description={item.description}
          />
        ))}
      </div>
    </div>
  );
}

export default App;

```

```react:List.tsx
import { RiDraggable } from "react-icons/ri";

type ListProps = {
  id: string;
  icon: JSX.Element;
  title: string;
  description: string;
};

export const List = (props: ListProps) => {
  return (
    <div className="flex items-center p-4 border-2 border-green-200">
      <div className="cursor-grab p-2">
        <RiDraggable size={"22px"} />
      </div>
      <div className="flex items-center gap-x-4">
        {props.icon}
        <div className="font-bold">{props.title}</div>
        <div className="">{props.description}</div>
      </div>
    </div>
  );
};

```

# Dragできるようにしよう
まずはハンドルを掴んで要素をDragできるようにします。まだDropはできません。
`List.tsx`に次のような関数を追加します。
`el`にはDragしたい要素全体が該当するように、`dragHandleEl`にはHandleとして掴みたい要素が該当するように設定してください。

```react:List.tsx
import { useEffect, useRef } from "react";
import invariant from "tiny-invariant";
import { draggable } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";

~~
const itemRef = useRef<HTMLDivElement | null>(null);
const dragHandleRef = useRef<HTMLDivElement | null>(null);

useEffect(() => {
    const el = itemRef.current;
    const dragHandleEl = dragHandleRef.current;
    invariant(el);
    invariant(dragHandleEl);

    draggable({
      element: el,
      dragHandle: dragHandleEl,
    });
  }, []);

```

さて、今はこんな状態です。
![draggable.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125627/5c3cd77f-d140-0edb-18f2-186ae2a00f26.gif)

ここでは直感的に理解できる関数しか出てきませんので大した説明はしません。
`draggable()`に`elementプロパティ`を含んだオブジェクトを突っ込むとDragできるようになるよってことがわかればOKです👍️ また、`dragHandle`は任意です。

少し趣向を凝らしていきましょう。
次の機能を追加します。
- Drag中にDragされている要素を薄く表示する
- 掴んでいる要素のタイトルだけを表示する

まずは次のようなフラグを追加して、スタイルを管理しましょう。

```diff_typescript
+  const [isDragging, setIsDragging] = useState(false);

~~~

return (
    <div
      ref={itemRef}
      className={`flex items-center p-4 border-2 border-green-200 ${
+        isDragging && "opacity-20"
      }`}
    >

~~~
```
これで`setIsDragging`を使ってフラグの切り替えをすればスタイルが切り替わるようになりました。`useEffect`でフラッグの切り替えを行っていきましょう。
さらに、現在の実装だと要素全てがカーソルについてきて少し邪魔ですので、タイトルだけついてきてもらうようにしましょう。
同ファイルの`useEffect`内に次のコードを追加します。

```diff_typescript
+ import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
+ import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview";


useEffect(() => {
    const el = itemRef.current;
    const dragHandleEl = dragHandleRef.current;
    invariant(el);
    invariant(dragHandleEl);

    draggable({
      element: el,
      dragHandle: dragHandleEl,
      onDragStart() {
        setIsDragging(true);
      },
+     onDrag() {
+       setIsDragging(true);
+     },
+     onDrop() {
+       setIsDragging(false);
+     },
+     onGenerateDragPreview({ nativeSetDragImage }) {
+       setCustomNativeDragPreview({
+         nativeSetDragImage,
+         getOffset: pointerOutsideOfPreview({ x: "16px", y: "16px" }),
+         render({ container }) {
+           container.innerHTML = `${props.title}`;
+         },
+       });
      },
    });
  }, []);

```
## 各プロパティ、関数の説明
### `onDragStart`, `onDrag`, `onDrop`
それぞれDragが始まったとき、Drag中、Dropしたときに発火する関数を設定できます。今回の例では`isDragging`が変更されています。
### `onGenerateDragPreview`
ドラッグプレビューを生成する際に呼び出されるコールバック関数です。
この中で、`setCustomNativeDragPreview`関数を使い、ドラッグプレビューをカスタマイズします。
- パラメータ`{ nativeSetDragImage }`
ブラウザのネイティブの`setDragImage`メソッド（ドラッグ時に表示されるイメージを設定するAPI）を提供します。これを利用することで、カスタムプレビューを設定可能です。
`setCustomNativeDragPreview`関数にぶちこむだけなので特に気にする必要はありません。
### `setCustomNativeDragPreview`
この関数でカスタムドラッグプレビューを設定します。
今回は、Dragしている要素のタイトルだけ表示したいのでしたね。引数としてオブジェクトを渡し、そのプロパティでプレビューを具体的に定義します。
では、具体的なプロパティを見ていきましょう。
- `nativeSetDragImage`
    前述の`nativeSetDragImage`をそのまま渡します。これにより、カスタムプレビューがブラウザのドラッグプレビューとして適用されます。
- `getOffset`
プレビューのオフセット（位置）を設定します。
`pointerOutsideOfPreview`は、カーソルの位置とプレビューのズレを定義するためのヘルパー関数です。今回はプレビューがカーソルから`x: "16px", y: "16px"`だけ離れるように設定しています。
- `render`
ドラッグプレビューの内容をレンダリングするための関数です。
引数`{ container }`はドラッグプレビューを描画するためのHTML要素（DOMノード）を提供します。
今回は`container.innerHTML`に`props.title`を挿入しており、ドラッグプレビューとしてタイトルに指定しているテキストが表示されます。

### 各種プロパティまとめ
はじめましての関数がたくさん出てきたので一旦整理します。
我々が今回注目すべきは、`onGenerateDragPreview`内部の`getOffset: pointerOutsideOfPreview`と`render`関数の2点だけです。
それはプレビューをどの位置に出すか、どんなプレビューを出すのか、です。
`render`関数に関しては今回は簡略化のためにこのような実装をしていますが、例えば次のようなコードを書けばもっと凝ったプレビューを表示させることもできます。
```react
const CustomPreview = ({ title }: { title: string }) => {
  return <div className="font-bold">{title}</div>;
}

onGenerateDragPreview({ nativeSetDragImage }) {
  setCustomNativeDragPreview({
    nativeSetDragImage,
    getOffset: pointerOutsideOfPreview({ x: "16px", y: "16px" }),
    render({ container }) {
      const root = ReactDOM.createRoot(container);
      root.render(<CustomPreview title={props.title} />);
    },
  });
}
```
さて、これらの実装を終えるとこんな状態になります。
![draggpreview.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125627/4fb620cf-e3f8-a9aa-6a2a-2d0c6cff76cb.gif)

# Dropで順番を変えよう
さて、いよいよ真打ちです。DnDでリストの順番を変えていきます。
すぐに順番を変えるコードを書いていきたいところですが、その前に下準備をします。

## Dropする対象の情報を処理する関数
`List.tsx`の`useEffect`内に次のコードを追加します。
```diff_typescript
useEffect(() => {

~~~

    draggable({
      element: el,
      dragHandle: dragHandleEl,
+     getInitialData() {
+       return { id: props.id };
+     },
      onDragStart() {
        setIsDragging(true);
      },
      onDrag() {
        setIsDragging(true);
      },
      onDrop() {
        setIsDragging(false);
      },
      onGenerateDragPreview({ nativeSetDragImage }) {
        setCustomNativeDragPreview({
          nativeSetDragImage,
          getOffset: pointerOutsideOfPreview({ x: "16px", y: "16px" }),
          render({ container }) {
            container.innerHTML = `${props.title}`;
          },
        });
      },
    });
+   dropTargetForElements({
+     element: el,
+     getData: ({ input, element }) => {
+       const data = {
+         id: props.id,
+       };
+       return attachClosestEdge(data, {
+         input,
+         element,
+         allowedEdges: ["top", "bottom"],
+       });
+     },
+   });
+ }, [props.id]);

```
## 各プロパティ、関数の説明
### `getInitialData()`
**Dragしている要素**の情報を返します。この`App.tsx`で`List.tsx`の情報をキャッチする実装を行いますが、その橋渡しをするのがこの関数です。returnするオブジェクトに渡したい情報を詰め込みます。`id`などDOMを識別するユニークな値は必ず含めるようにしてください。
### `dropTargetForElements`
この関数は、要素をDrop可能な領域として設定します。つまり**Drop先**の情報を扱う関数です。
- `element`
`draggable`のときと同様に対象DOMのref要素を指定します。あまり気にする必要はありません。とりあえず書いておきましょう。
- `getData`
Drag中に、Drop可能なデータを計算・設定するための関数です。
    - 引数:
        - `input`: Drag中のポインタやDrop先の詳細情報。
        - `element`: Drop先の要素。
    - 処理:
Drop先の情報をdataオブジェクトに格納し、`attachClosestEdge`を利用して、ポインタの位置に基づいて「最も近いEdge」（`top`または`bottom`）をデータに追加します。
- `attachClosestEdge`
    `input`と`element`の情報からDrop先でポインタがどのEdge（上部や下部）に近いかを判断し、その情報を`data`に追加します。今回は要素の横にはDropできなくするために`top`と`bottom`を指定しています。

:::note
**Edge（エッジ）ってなんやねん**
Pragmatic DnDではEdgeを使ってDrop先を識別します。Edgeは`top`, `bottom`, `right`, `left`があり、Drag中の要素がDrop先要素のどのEdgeに近いのかを判別し、dataオブジェクトに追加します。
:::

そしてこれら2つの関数は同じタイミングで行われます。このように発火タイミングが同じクリーンアップ関数は`combine`というユーティリティでマージすることが公式から推奨されています。`combine`を追加して2つの関数をラップします。
関数の終わりにつけるセミコロンはそのままだとエラーが起きるのでカンマにしておいてくださいね。

```diff_typescript
+ import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";

useEffect(() => {
~~~

+   combine(
      draggable({
       ~~~
      }),
      dropTargetForElements({
        ~~~
      })
+   );

```


さて、これで`List.tsx`から`App.tsx`に渡す、dataオブジェクトの設定とその橋渡しが終わりました。
次は`App.tsx`に`List.ts`xからの情報をキャッチして順番を並び替える処理を実装します。こっちは案外簡単です。`App.tsx`に次のように追加します。

```react:App.tsx
import { useState, useEffect } from "react";
import { extractClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { reorderWithEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/util/reorder-with-edge";
import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";

~~~
  const [listItem, setListItem] = useState(array);

  useEffect(() => {
    return monitorForElements({
      canMonitor({ source }) {
        return source.data && source.data.id !== null;
      },
      onDrop({ source, location }) {
        const target = location.current.dropTargets[0];
        if (!target) return;

        const sourceData = source.data;
        const targetData = target.data;
        if (!sourceData || !targetData) return;

        const indexOfSource = listItem.findIndex(
          (item) => item.id === sourceData.id
        );
        const indexOfTarget = listItem.findIndex(
          (item) => item.id === targetData.id
        );

        if (indexOfTarget < 0 || indexOfSource < 0) return;

        const closestEdgeOfTarget = extractClosestEdge(targetData);

        // DOMを更新するためにflushSyncを使用
        flushSync(() => {
          setListItem(
            reorderWithEdge({
              list: listItem,
              startIndex: indexOfSource,
              indexOfTarget,
              closestEdgeOfTarget,
              axis: "vertical",
            })
          );
        });
      },
    });
  }, [listItem]);

  ~~~

```

まず`useState`で配列を管理します。`return`以降で展開する部分もstateで管理している変数に変更しておいてください。
そして、`monitorForElements`で行われるのが配列を並び替える処理ですので、`useEffect`で実行タイミングを制御します。今回依存配列に入るのは`listItem`になりますね。

## 各プロパティ、関数の説明
### **`reorderWithEdge`**
あえて、最後の方に記述されるこの関数を最初に紹介します。こいつは必要な情報さえ与えればいい感じに並び替えてくれる、なんかすっごくすごい関数です。
ですのでこの関数を埋めるようにしてその他の関数や処理を書いていきます。
そして`reorderWithEdge`は次のようなプロパティを持つオブジェクトを渡せば動きます。それぞれどんな情報を渡せばよいのかは名前から一目瞭然ですので簡単な説明に留めます。
- `list`
変更対象の配列を渡します。1次元配列のみで、多次元配列は与えることができません。
- `startIndex`
Dragするオブジェクトの`index`を渡します。
- `indexOfTarget`
Drop対象のindexを渡します。
- `closetEdge`
Drop先で最も近いEdgeを渡します。
- `axis`
挿入する方向を`vertical`か`horizontal`から選ぶことができます。

ここまでで足りていない情報は、`startIndex`, `indexOfTarget`, `closestEdgeOfTarget`ですね。この3つを埋めることを目標に実装を進めていきます。

### `monitorForElements`
DnDの操作を監視する関数です。`canMonitor`や`onDrop`などのコールバックを利用して、Drag操作が可能かどうかの判定やDrop後の処理を記述します。この関数の中に、さきほどの`reorderWithEdge`が入っていますね。ではこの中に記述する関数を見ていきましょう。
### `canMonitor`
Dragする要素のdataを監視します。ここでは`source`オブジェクトの中にある`id`が存在するかどうかを確認しています。
### `onDrop`
Dropイベントが発生したときに実行されるコールバックです。コードを見ると分割代入で`source`と`location`がありますが、これらはそれぞれ次の情報を持っています。
- `source`: Dragされている要素の情報を持つオブジェクト
- `location`: Dropが発生した位置の情報を持つオブジェクト

さて、私達がほしいのは`startIndex`, `indexOfTarget`, `closestEdgeOfTarget`の3つでしたね。`startIndex`と`indexOfTarget`はここで入手することができそうです。
特に難しく考えることはなく、本当に`id`が存在し、無効でないかをチェックしたら`findIndex`でそれぞれの`index`を取得しましょう。
### `extractClosestEdge`
`List.tsx`で`Edge`を与えたのを覚えていますか？
この関数は`attach`した`Edge`を引っ張り出す関数で、Drop処理の中でついでに行います。もしこの関数を書いて、`closestEdge`を設定したのにうまく動作しない場合は`attach`側を疑うとよいです。

さあ、これで役者がそろいました。`reorderWithEdge`にセットしてローカルで動かしてみましょう。
このようにリストの順番が変えられるようになりましたか？
動いているなら大成功です。お茶でも飲みましょうｵﾁｬｱ(｢🍵･ω･)｢🍵
![reorder.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125627/e92fdd91-7a6e-d864-9f6e-aef9ac8c56c1.gif)

# Indicatorを表示してListを完成させよう
今のままだと、どこにDropされるのかわからないのであまりUXがよいとは言えませんね。
Drop先のプレビューを表示してくれる`Indicator`を表示させて、この実装を終えましょう。

次のようなコードを追加します。

```diff_typescript
import {
+ type Edge,
  attachClosestEdge,
+ extractClosestEdge,
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
+ import { DropIndicator } from "@atlaskit/pragmatic-drag-and-drop-react-drop-indicator/box";

~~~

export const List = (props: ListProps) => {
  const [isDragging, setIsDragging] = useState(false);
+ const [closestEdge, setClosestEdge] = useState<Edge | null>(null);

~~~

      dropTargetForElements({
~~~
+      onDragEnter: ({ self }) => {
+         const currentEdge = extractClosestEdge(self.data);
+         setClosestEdge(currentEdge);
+       },
+       onDrag: ({ self }) => {
+         const currentEdge = extractClosestEdge(self.data);
+         setClosestEdge(currentEdge);
+       },
+       onDragLeave: () => setClosestEdge(null),
+       onDrop: () => setClosestEdge(null),
+       canDrop: (arg) => {
+         if (arg.source.data.id === props.id) {
+           return false;
+         }
+         return true;
+       },
+     })
    );
  }, [props.id]);

return (
    <div
      ref={itemRef}
+     className={`relative flex items-center p-4 border-2 border-green-200 ${
        isDragging && "opacity-20"
      }`}
    >
+     {closestEdge && <DropIndicator edge={closestEdge} />}
      <div ref={dragHandleRef} className="cursor-grab p-2">
        <RiDraggable size={"22px"} />
      </div>

```
## 各プロパティ、関数の説明
### **canDrop**
この関数では、Dropできるかどうかの設定を行うことができます。
真偽値を返してやる必要があり、`false`の場合、Dropできなくなるだけでなく、**`Indicator`も表示されなくなります。**
`arg`の中には、`input`, `element`, `source`が入っていますが、`source`にDragしている要素にあたる情報が入っているのでそれを使いましょう。
Drop先の情報は`props`か`element`を使います。今回は`props`を用いました。それぞれの中身については開発者ツールで確認してください。
今回はDragしている要素自身にも`Indicator`が表示されることを防ぐためにこのような実装をしています。

新しい概念も難しい処理も~~私の気力も~~ほとんど無いので、新しく出てきたcanDropについてのみ言及しました。残りの処理について軽く説明します。
`useState`で`Edge`を管理します。Edge型という型があるので一緒にインポートします。
既に`Edge`は`attach`してあるのであとは引き出して、`state`を更新するだけです。
分割代入で得られる`self`には**Dropターゲットの情報**が入っています。

さて、ではreactのHTML部分に`DropIndicator`を配置し、`closestEdge`を渡しましょう。
ここで1点注意ですが、**`Indicator`を囲む`div`には`position: relative;`を設定してください。**
さもないと`Indicator`くんが旅に出てしまいますのでご注意を。

最後にタイトルをUndraggableListからDraggableListに変えれば...

# 完成！
お疲れ様でした。
これでListの順番がDnDで変更できるようになりましたね。
このようになっていればOKです。

長々といろいろ書きましたが、全体的な実装の流れとしては

1. 子要素をDragできるようにする
1. 子要素のデータをデータを取得し、Edgeを付与する
1. 親要素で渡されたデータとEdgeをキャッチし、`reorderWithEdge`で並び替える
1. Edgeに基づいて`Indicator`を表示し、必要に応じて`canDrop`で制御する

と考えるとスムーズかと思います！

![done.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125627/932a7106-193f-7007-4b3a-4818eee4710a.gif)



# PR
株式会社HRBrainでは、一緒に働く仲間を募集しています！

興味を持っていただいた方はぜひ弊社の採用ページをご確認ください！
HRBrain文化を一緒に作っていきましょう！

https://hrbrain.co.jp/recruit