💭

木構造の DnD に適した処理を考える

2021/06/25に公開

DnD は考えることが多い。大抵のライブラリは特定のユースケースにべったりで、毎回自分で書く羽目になる。

とくに、木構造の DnD をどう表現するかが難しい。特にWeb上でファイラーのようなUIを実装する頻度が高く、その求められる実装が毎回違うので、自分が考えていることを一般化してみる。

この記事はコードをコピペしたら使えるものではなく、あくまで考え方をコードに落としたもの、ということに注意。

今回は前提として、こういうものを作っていた。

DnD の要件

  • DOM ベースの sortable ライブラリはいっぱいあるが、DOMをマスターデータとして扱うタイプが多く、現代のフレームワークと噛み合わない。可能な限りデータを元に表現して、最後に変更したデータを render するだけとする。
  • フレームワーク非依存な処理を切り出して、UIを通さずにテストを書いたり、ポータブルに扱えるようにしたい。
  • 扱う対象が木構造だとしても、 DnDの実装をする際に再帰的に展開すると event bubbling の制御が難しくなるので、一次元のデータ構造に落とした方が取り扱いやすい

木構造 => 一次元のデータ構造に変換

対象としたい木構造を、一度深さ情報と親idを付与したデータに変換する。

// なんらかの木構造のデータ
type Node = {
  id: string;
  type: string,
  data: number;
  children: Node[];
}

type FlatNode = {
  id: string;
  parentId: string,
  type: string,
  data: number;
  depth: number;
}

// フラットなツリーに展開する
function toFlat(root: Node): FlatNode[] {
  let list: FlatNode[] = [];
  function walk(node: Node, parent: Node, depth: number = 0) {
    list.push({
      id: node.id,
      parentId: parent?.id,
      type: node.type,
      data: node.data,
      depth: depth
    });
    node.children.forEach(c => walk(c, node, depth + 1));
  }
  walk(root, null, 0);
  return list;
}

// 操作する
const tree = {...}; // 扱いたいデータ
const items = toFlat(tree);

この 一次元展開された items を一次元の List View として展開する…前に、その操作ロジックを実装する。

DnD ロジックの実装

flat 化されたリストの操作から source (ドラッグ対象) と target (ドロップ対象) に関心を絞って実装を行う

  • target に対して source をドロップ可能かの関数を定義する
  • drop するときに、 フラット化されたデータ構造ではなく、元データへの変形を実装する
  • 変形を終えたら再度 flat 化する

ここでは immer を使って mutable なデータとして扱っている。

import {produce} from "immer";

type Rule = {
  ruleName: string;
  canDrop: (source: FlatNode, target: FlatNode) => boolean;
  transform: (root: Node, source: FlatNode, target: FlatNode) => Node;
};

// 同じ兄弟間でのみ入れ替えを許可する
const swapRule: Rule = {
  ruleName: 'swap',
  canDrop(source, target) {
    return (
      source.parentId === target.parentId &&
      source.id !== target.id
    );
  },
  transform(root, source, target) {
    return produce(root, draft => {
      function walk(node: Node, parent: Node, depth: number = 0) {
        if (parent.id === target.id) {
          const aIndex = parent.children.findIndex(c => (c.id === aid);
          const bIndex = parent.children.findIndex(c => (c.id === bid);
          const a = parent.children[aIndex];
          const b = parent.children[bIndex];
          parent.children[aIndex] = b;
          parent.children[bIndex] = a;
        }
        node.children.forEach(c => walk(c, node, depth + 1));
      }
    });
  },
};

const dndRules: Rule[] = [swapRule, moveToOtherNodeRule];

// どのルールにマッチしたか取得する関数を用意
function matchRule(source: FlatNode, target: FlatNode): Rule | void {
  return dndRules.find(r => r.canDrop(source, target));
}

今回は 入れ替えのコードの実例を書いた。(元あるコードから切り出したので動いてないかも。あくまでサンプル)

今回は単純化してるデータ構造で記述してるが、本来なら File と Directory だったら、 とか、 File は File としか入れ替えられないとか、 Directory にしか入らない、みたいなルールを書く必要がある。必要なデータは Node オブジェクトに足す。

canDrop を分離しているのは、source をドラッグ開始したときに、 drop 可能な target をハイライトしたい、というときに使えるようにするため。

これらを使って、実際のViewを構築する

View の実装

ここからフレームワーク依存の処理になるが、基本的な発想は、 DnD で交換したい id を取り出し、それを先に定義した dnd rules にわたすだけ、とする。

drop 可能な領域のプレビューをするために、drag 対象を掴んだときに、リストの各要素に対して canDrop で判定してハイライトする、みたいな実装も考えられる。

<script lang="ts">
  // ...
  let root: Node = {...};
  let items: FlatNode[] = toFlat(root);

  // drag 中の
  let draggingSource: FlatNode | null = null;
  const onDrop = (target: FlatNode) => {
    if (draggingSource) {
      for (const rule of dndRules) {
        if (rule.canDrop(draggingSource, target)) {
          root = rule.transform(root, draggingSource, target);
          items = toFlatNodeList(root);
          break;
        }
      }
    }
  }
</script>

<div style="width: 500px; ">
  {#each items as item, index (item.id)}
    <div
      style="display: flex; width: 100%"
      draggable={true}
      on:drop={onDrop}
      on:dragstart={event => {
        draggingSource = item;
        // setData することで drag 可能の判定になる
        event.dataTransfer.setData('text/plain', item.id);
      }}
      on:dragend={() => {
        draggingSource = null;
      }}
      on:dragover={ev => {
        if (draggingSource && matchRule(draggingSource, item)) {
          // dragover で preventDefalut することで ドロップ可能判定される
          ev.preventDefault();
        }
      }}
    >
      <!-- depth を元にインデントを表現 -->
      <div style="width: {item.depth}rem;" />
      <div style="flex: 1">
        <!-- ここに展開したい View を展開 -->
      </div>
    </div>
  {/each}
</div>

Node の拡張とドロップ用の余白

拡張していくと、 DnD の余白の空の行を作りたくなった。要素と要素の間に drop したりするため。

// ...
type FlatNode = {
  type: 'draggable',
  id: string;
  parentId: string,
  data: number;
  depth: number;
} & {
  id: string,
  parentId: string,
  type: 'spacer',
  index: number,
  depth: number
}


function toFlat(root: Node): FlatNode[] {
  let list: FlatNode[] = [];
  function walk(node: Node, parent: Node, depth: number = 0) {
    list.push({
      id: node.id,
      parentId: parent?.id,
      type: node.type,
      data: node.data,
      depth: depth
    });
    const selfIndex = parent?.children.findIndex((c) => c.id === node.id);
    list.push({
      type: 'spacer',
      index: selfIndex,
      id: node.id + ":spacer",
      parentId: parent?.id,
      depth: depth
    });
    node.children.forEach(c => walk(c, node, depth + 1));
  }
  walk(root, null, 0);
  return list;
}

このように隙間のデータを挿入して、描画しつつ dndRule を追加してロジックを書けばいい。

Discussion