ドラッグ&ドロップ可能なツリーの実装

536
NO IMAGE

以下のような要件で、ドラッグ&ドロップ可能なツリーを作ることになったのですが、思いのほか簡単に実装ができたので、その方法を紹介します。

  • Vue.js + TypeScriptで実装する
  • ドラッグすると、ツリーの子、子孫も同時にドラッグされる
  • ツリーのノードにドロップすると、ドロップ先のノードの子として追加される
  • 同一階層内での順序の入れ替えはない

Vue.jsでドラッグ&ドロップを簡単に実現するためのVue.Draggableというライブラリがありますが、要件を満たすように実装するのが難しそうだったので、ライブラリを使わずに実装することにしました。

まず、基本的な手順として、以下を行いました。

  1. Vue.jsの公式サイトにある「ツリー表示の例」をTypeScriptで書き換える
  2. MDN Web Docsの「HTML ドラッグ&ドロップ API」に従って、ドラッグ&ドロップを実装する

その結果がこちらです。

tree.vue

<template>
  <ul id="demo">
    <tree-item
      class="item"
      :item="treeData"
      @make-folder="makeFolder"
      @add-item="addItem"
    ></tree-item>
  </ul>
</template>

<script lang="ts">
import Vue from 'vue'
import { Component } from 'vue-property-decorator'
import uuid from 'v4-uuid'
import TreeItem, { TreeData } from './tree-item.vue'

@Component({
  components: { TreeItem }
})
export class Tree extends Vue {
  treeData?: TreeData;

  data () {
    return {
      treeData: {
        id: uuid(),
        name: 'My Tree',
        children: [
          { id: uuid(), name: 'hello' },
          { id: uuid(), name: 'wat' },
          {
            id: uuid(),
            name: 'child folder',
            children: [
              {
                id: uuid(),
                name: 'child folder',
                children: [{ id: uuid(), name: 'hello' }, { id: uuid(), name: 'wat' }]
              },
              { id: uuid(), name: 'hello' },
              { id: uuid(), name: 'wat' },
              {
                id: uuid(),
                name: 'child folder',
                children: [{ name: 'hello' }, { name: 'wat' }]
              }
            ]
          }
        ]
      }
    }
  }

  makeFolder (item: TreeData) {
    this.$set(item, 'children', [])
    this.addItem(item)
  }

  addItem (item: TreeData) {
    if (item?.children !== undefined) {
      const index = Object.keys(item.children).length + 1
      item.children.push({
        id: uuid(),
        name: 'new stuff'
      })
    }
  }
}

export default Tree
</script>

tree-item.vue

<template>
  <li
    draggable="true"
    @dragstart ="onDragStart"
  >
    <div :class="{ bold: isFolder }" @click="toggle" @dblclick="makeFolder"
      @dragover.prevent="onDragOver"
      @drop.prevent="onDrop"
    >
      {{ item.name }}
      <span v-if="isFolder">[{{ isOpen ? "-" : "+" }}]</span>
    </div>
    <ul v-show="isOpen" v-if="isFolder">
      <tree-item
        class="item"
        v-for="(child, index) in item.children"
        :key="index"
        :item="child"
        @make-folder="$emit('make-folder', $event)"
        @add-item="$emit('add-item', $event)"
      ></tree-item>
      <li class="add" @click="$emit('add-item', item)">+</li>
    </ul>
  </li>
</template>

<script lang="ts">
import Vue from 'vue'
import { Component, Prop } from 'vue-property-decorator'

export interface TreeData {
    id: string;
    name: string;
    children?: TreeData[];
}

@Component({
  components: {}
})
export class TreeItem extends Vue {
  @Prop()
  item?: TreeData;

  isOpen = false;

  get isFolder () {
    return this.item !== undefined && this.item.children && this.item.children.length
  }

  toggle () {
    if (this.isFolder) {
      this.isOpen = !this.isOpen
    }
  }

  makeFolder () {
    if (!this.isFolder) {
      this.$emit('make-folder', this.item)
      this.isOpen = true
    }
  }

  onDragStart (event: DragEvent) {
    // ここは後で実装する
  }

  onDragOver (event: DragEvent) {
    if (event!.dataTransfer !== null) {
      event.dataTransfer.dropEffect = 'move'
    }
  }

  onDrop (event: DragEvent) {
    // ここは後で実装する
  }
}

export default TreeItem
</script>

<style scoped>
.bold {
    font-weight: bold;
}
</style>

MDN Web Docsのサンプルでは、ドロップされた時にDOMを直接更新してノードの付け替えをしています。しかし、Vue.jsではモデルの変更に対してリアクティブにDOMが更新されるため、モデル(ツリー全体の構造)を更新する必要があります。
そのため、onDragStartイベントハンドラー、onDropイベントハンドラーを以下のように実装しました。

tree-item.vue

onDragStart (event: DragEvent) {
  if (this.item !== undefined) {
    if (event!.dataTransfer !== null) {
      event.dataTransfer.setData('item', this.item.id)
      event.dataTransfer.dropEffect = 'move'
    }
  }
}

onDrop (event: DragEvent) {
  if (event!.dataTransfer !== null) {
    event.dataTransfer.dropEffect = 'move'
    const item = event.dataTransfer.getData('item')
    // ここでノードの付け替えが必要
  }
}

ここで、ツリーならではのワナにハマりました。onDropのitemをデバッグ出力してみたら、ドラッグしたノードではなく、そのノードの最上位の親になっていたのです。
原因は単純で、dragstartイベントが最上位の親まで伝播していたことです。これを防ぐために、onDropイベントハンドラーを以下のように書き換えました。

tree-item.vue

onDrop (event: DragEvent) {
  if (event!.dataTransfer !== null) {
    event.stopPropagation() // 親へのイベントの伝播を防ぐ
    event.dataTransfer.dropEffect = 'move'
    const item = event.dataTransfer.getData('item')
    // ここでノードの付け替えが必要
  }
}

onDropメソッドはTreeItemクラスのメソッドですが、ツリー全体の構造を知っているのはTreeクラスです。そのため、Treeクラスにモデルを更新してもらうために、TreeItemクラスでイベントを発火させ、ドラッグしたノードのid、ドロップ先のノードのidを最上位の親まで伝えます。また、Treeでそのイベントハンドラーも必要となります。

その結果のコードがこちらです。

tree-item.vue

(略)...
<ul v-show="isOpen" v-if="isFolder">
  <tree-item
    ...(略)
    @drag-drop="$emit('drag-drop', $event)"
  ></tree-item>
  ...(略)
</ul>

onDrop (event: DragEvent) {
  if (event!.dataTransfer !== null) {
    event.stopPropagation() // 親へのイベントの伝播を防ぐ
    event.dataTransfer.dropEffect = 'move'
    const item = event.dataTransfer.getData('item')
    this.$emit('drag-drop', { drag: item, drop: this.item.id }) // イベントを発火し、ドラッグしたノードのid、ドラッグ先のノードのidを伝える
  }
}

(略)...

tree.vue

onDragDrop (value: { drag: string; drop: string }) {
  // treeDataのノードの付け替えをする(やや煩雑なため省略)
}

今回、ライブラリを使わずにドラッグ&ドロップ可能なツリーを実装しましたが、実装自体が予想外に簡単だっただけでなく、自分で実装し中身の理解ができているため、この後の様々なカスタマイズが簡単になったという利点も大きかったです。