🖐️

TiptapでDrag Handleを自作する

2024/09/28に公開

こんにちは!フライドポテトが好きなかりんとうです。

最近リッチテキストエディター(RTE)を開発する上で、Drag Handleを実装したい要望が出てきたので、それを自前で実装する方法を記述していきたいと思います。
例えばNotionだと、ブロックを Drag and Drop(DnD) できる箇所です。

notion drah handle

RTEでは色々な要素がエディタ内に含まれるので、利便性のためにこのような機能がある場合があります。
私はProseMirrorのラッパーであるTiptapを使って開発をしているのですが、残念ながら有料機能で使うことができません。
しかし、実装の詳細は見ることは出来ないのですが、その拡張機能のインターフェースとサンプルは公式サイトで見れるので、そこから中身を予想して実装します。

対象読者

  • Tiptap + React でDrag Handleを実装したい人
  • RTEでのDrag and Dropがどのように実装されているのか気になる人

今回の成果物

https://karintou8710.github.io/tiptap-drag-handle-sample/

https://github.com/karintou8710/tiptap-drag-handle-sample

DnDの基本

まずはDnDがWeb標準でどのように実装されているのかを知りたいです。
最も基本的な、特定の要素をドラッグ可能にして、特定の要素内にドロップする例を考えてみます。
動くサンプルがあった方が理解しやすいので、codesandboxで簡易的なものを作りました。(MDNを参考)

この例では、「ドラッグ可能」と書かれたpタグを、正方形の要素にドロップできるサンプルです。

まず、HTMLはdraggable属性がtrueになっているpタグと、ドロップゾーンの2つあります。

 <div id="app">
    <p draggable="true" id="draggable">ドラッグ可能</p>
    <div id="dropzone"></div>
</div>

draggable="true"は重要で、指定した要素がドラッグ可能になります。ドラッグ可能な状態とは、各種ドラッグ関連のイベントが発火し、ドラッグ中に要素の画像が表示される状態です。

次に要素をドロップゾーンに配置する方法ですが、ドラッグ中のイベントに情報を持たせることができるので、要素のIDを受け渡します。

まずドラッグ対象ですが、以下のようにdragstartイベントを設定します。

const draggableEl = document.getElementById("draggable");

function dragstart_handler(ev) {
  ev.dataTransfer.setData("text/plain", ev.target.id);
  ev.dataTransfer.effectAllowed = "move";
}

draggableEl.addEventListener("dragstart", dragstart_handler);

ドラッグの開始と同時に、dataTransfer.setDataでIDを渡します。これをドロップゾーンで受け取ります。

const dropzoneEl = document.getElementById("dropzone");

function dragover_handler(ev) {
  ev.preventDefault(); // これがないとdropイベントが発火しない
  ev.dataTransfer.dropEffect = "move";
}

function drop_handler(ev) {
  ev.preventDefault();
  const data = ev.dataTransfer.getData("text/plain");
  ev.target.appendChild(document.getElementById(data));
}

dropzoneEl.addEventListener("dragover", dragover_handler);
dropzoneEl.addEventListener("drop", drop_handler);

受け取るために、dragoverdropイベントを設定します。
dragoverはドラッグの対象がドロップゾーン範囲内で動いているときに発火します。dropを発火させるために、preventDefaultを実行します。
ドラッグ状態を解除すると、dropが発火します。ここで、前の段階で設定したIDをイベントから取得して、ターゲット要素にappendChildしています。

以上がDnDの基本になります。お気づきの方もいるかと思いますが、グローバルな状態でデータの受け渡しも可能です。状況によって適切な方針を立てると良さそうです。

実装のポイント

DnDの基本が理解できて早速RTEで開発したいところですが、実装にあたってもう1段階踏み込む必要があります。今回の要件での懸念点は以下の2つです。

  • ドラッグしたい要素はブロックだが、ドラッグの起点となる要素は隣のアイコン
  • このアイコンはブロックの上にカーソルがあっている時だけ表示したい

隣のアイコンをドラッグの起点にする

まず1つ目ですが、DnDの基本で確認した通り、ドラッグ対象とドロップゾーンとのデータの受け渡しに制限はありません。現在選択中の要素を管理しておき、dragstart時にそのデータを受け渡しすれば良さそうです。
しかしドラッグ中に表示される画像がドラッグアイコンになり、対象のブロックではないのが問題です。
ありがたい事に、ドラッグ中の画像を設定できるev.dataTransfer.setDragImageというメソッドがあるので、これを選択中のブロックに設定することで解決できます。

選択中のブロックにのみアイコンを表示

次に2つ目ですが、これは選択中のブロックを常に管理しておくことで達成できます。その絶対位置を取得し、隣にアイコンを表示するような実装をすればいいです。

Tiptapで実践

それではTiptapで実装をします。ポイントだけ抜粋して解説するので、より詳細のコードはレポジトリを確認してください。

まずは、DragHandleをReactコンポーネントとして実装します。ドラッグアイコンを起点に実装するイメージです。

type Props = {
  editor: Editor;
};

export default function DragHandle({ editor }: Props) {
  return (
    <div
      draggable="true"
      className={styles.icon}
    >
      <svg
        xmlns="http://www.w3.org/2000/svg"
        fill="none"
        viewBox="0 0 24 24"
        strokeWidth="1.5"
        stroke="currentColor"
      >
        <path
          strokeLinecap="round"
          strokeLinejoin="round"
          d="M3.75 9h16.5m-16.5 6.75h16.5"
        />
      </svg>
    </div>
  );
}

このコンポーネントに機能を追加していきます。(雑にTiptapのサンプルと同じsvgを使っている)

選択中の要素情報を保持

まずは事前準備として、現在選択中の要素に関する状態を管理します。
エディタ内の位置情報だけあれば良さそうですが、利便性のために選択中ノードのトップレベル(depth=1)にあるdomNodeSelectionを状態として保持します。

実装方針は、エディタのルート要素 (editor.view.dom) にmousemoveイベントを設定します。
mousemoveのイベントから取得できるdom位置から、view.posAtCoordsでエディタ位置に変換。そして、その位置からトップレベルのdomNodeSelectionを生成して状態管理します。

以下はエディタ内の位置情報取得までのコードです。

type DragInfo = {
  dom: HTMLElement;
  nodeSelection: NodeSelection;
};

// 省略

  const [dragInfo, setDragInfo] = useState<DragInfo | null>(null);

  useEffect(() => {
    const handleMouseMove = (ev: MouseEvent) => {
      const pos = editor.view.posAtCoords({
        left: ev.clientX,
        top: ev.clientY,
      });
      if (!pos) return;

      setTopBlockDragInfo(Math.min(pos.pos, editor.state.doc.content.size - 1));
    };

    editor.view.dom.addEventListener("mousemove", handleMouseMove);
    return () => {
      editor.view.dom.removeEventListener("mousemove", handleMouseMove);
    };
  }, [editor, setTopBlockDragInfo]);

// 省略

setTopBlockDragInfoはエディタ位置からdomNodeSelectionを取得して保存する関数です。

 const setTopBlockDragInfo = useCallback(
    (pos: number) => {
      const $pos = editor.state.doc.resolve(pos);

      setDragInfo({
        dom: editor.view.domAtPos($pos.start(1)).node as HTMLElement,
        nodeSelection: NodeSelection.create(editor.state.doc, $pos.before(1)),
      });
    },
    [editor]
  );

基本的に取得したエディタ位置がノード内の場合はトップレベルの親要素、ノード外の場合は最も近い次のトップレベル要素を取得する実装になっています。

ドラッグアイコンの位置を調整

ドラッグアイコンは選択中要素の左側に設置したいです。
方針的には、その絶対座標からドラッグアイコンを配置する事にします。

dom.getBoundingClientRect()で表示領域内での位置が取得できるので、window.scrollYwindow.scrollXをそれぞれ足して、絶対位置にします。
少々左寄りに設置するため、leftを-40pxしています。


  const rect = dragInfo.dom.getBoundingClientRect();

 return (
    <div
      draggable="true"
      className={styles.icon}
      style={{
        top: rect.top + window.scrollY,
        left: rect.left + window.scrollX - 40,
      }}
    >
     ...
    </div>
);
.icon {
  width: 24px;
  height: 24px;
  position: absolute;
  cursor: grab;
}

DnDの機能実装

dragstartイベントをドロップアイコンに設定します。
ありがたいことに、view.draggingに必要な情報を詰め込むと、あとはProseMirrorがdrop処理を担当してくれます。
グローバルな状態に詰め込んで、dropイベントに渡すパターンと似ています。

該当するProseMirrorの処理

他に、状態管理しているdomでsetDragImageを実行しドラッグ中の画像を生成します。
以下が該当箇所のコードです。

 const handleDragStart = useCallback(
    (ev: DragEvent) => {
      if (dragInfo === null) return;

      ev.dataTransfer.setDragImage(dragInfo.dom, 0, 0);
      ev.dataTransfer.effectAllowed = "copyMove";
      editor.view.dragging = new Dragging(
        dragInfo.nodeSelection.content(),
        true,
        dragInfo.nodeSelection
      );
    },
    [editor, dragInfo]
  );

拡張機能の使い方

EditorContentと兄弟要素になるように配置すればいいです。

export default function Editor() {
  const editor = useEditor({
    extensions: extensions,
    content: content,
  });

  if (!editor) return null;

  return (
    <>
      <EditorContent editor={editor} />
      <DragHandle editor={editor} />
    </>
  );
}

最後に

今回はTiptapでDrag Handleを実装してみました。
Tiptapで実装と書いてありますが、ほとんどProseMirrorの機能を使って実装しています。
なのでProseMirror単体で実装することも、さほどコストなく出来ると考えています。

また、今回はParagraphだけ実装しましたが、ネストされたノード、AtomノードやNodeView対応など、実際に運用する上では考えることが他にも沢山あります。

本記事がDrag Handleを実装する際のとっかかりになれば嬉しいです!

Discussion