本ブログでは何度かドラッグ&ドロップの実装方法を紹介してきましたが、本文書ではJavaScriptのマウスイベントを利用してTrelloのタスク並び替えのドラッグ&ドロップのクローンを作成していきます。mouseイベントについても利用する個々のイベントについて細かく説明しています。

Trelloドラッグ&ドロップクローンの特徴は下記の通りです。

  • draggabl属性(dragイベント)を利用せずmouseイベントを利用して作成しています。
  • タスクの要素をドラッグしている間ドラッグしているタスクの領域を確保しています。
  • ドラッグを開始すると要素をわずかに傾けます
  • タスク要素の高さの中心をマウスが超えるとタスクの移動が行われます。(上下の並び替えの場合)
  • カテゴリー間の並び替えでは別のカテゴリーにタスク要素の半分が入ると並び替えが起こります。

説明ではわかりにくいと思うので実際の動作は下記のようになります。

完成時のTrelloクローン
完成時のTrelloクローン

構築環境

手元の環境でも本アプリケーションを作成できるように特別な環境は構築せずcdnを利用して行います。Vue.jsとtailwindcssのみ利用しています。


<script src="https://unpkg.com/vue@next"></script>
<link href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet">
ドラッグ&ドロップのライブラリは一切利用していません。

スクラッチから始めるので最初にindex.htmlファイルを作成して以下のVue.jsのコードを記述します。


<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <script src="https://unpkg.com/vue@next"></script>
  <link href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet">
  <title>Trelloタスク並び替えドラッグ&ドラッグクローン</title>
</head>
<body>
  <div id="app">
  </div>
</body>
</html>
<script>
  const app = Vue.createApp({

  }).mount('#app')
</script>

ブラウザで閲覧しても何も表示されません。

リストとタスクの表示

リストとタスクの関係は以下の通りで、リストの中に複数のタスクが含まれており、リスト毎に領域が分かれています。本アプリケーションでは3つのリストと合計8個のタスクを準備します。

リストとタスク
リストとタスク

利用するリスト、タスクデータ

準備したデータをデータプロパティのlistsに設定します。


data(){
  return {
    lists: [
      {
        id: 1,
        name: 'ToDo',
        tasks: [
          {
            id: 1,
            name: 'レポートの作成',
            description: 'コロナに影響による飲食店の倒産件数の調査',
            user_name: '鈴木',
          },
          {
            id: 2,
            name: '業界の調査',
            description: '',
            user_name: '佐藤',
          },
          {
            id: 3,
            name: 'ウェビナーの開催',
            description: '',
            user_name: '鈴木',
          },
          {
            id: 7,
            name: 'メルマガの送信(毎週)',
            description: '',
            user_name: '鈴木',
          },
          {
            id: 8,
            name: '社内セキュリティトレーニング再テスト',
            description: '',
            user_name: '鈴木',
          },
        ]
      },
      {
        id: 2,
        name: '作業中',
        tasks: [
          {
            id: 4,
            name: '見積もりの作成',
            description: '',
            user_name: '山田',
          },
        ]
      },
      {
        id: 3,
        name: '完了',
        tasks: [
          {
            id: 5,
            name: 'B社への支払い',
            description: '経理への連絡を忘れないように',
            user_name: '鈴木',
          },
          {
            id: 6,
            name: '鈴木さんの休暇申請承認',
            description: '',
            user_name: '佐藤',
          },
        ]
      },
    ]
  }
}

リストデータの表示

listesをv-forディレクティブで展開し、flexboxを利用してリストを横並びに表示します。各リストの幅は260pxに設定しています。


<div id="app">
  <div id="trello" class="flex m-10">
    <div 
      v-for="(list,index) in lists" 
      :key="index" 
      style="min-width:260px"
      class="m-2 bg-gray-200 rounded-lg h-full"
    >
      <div class="font-bold text-sm p-4">{{ list.name }}</div>
    </div>
  </div>
</div>

ブラウザで確認するとリスト名のみ横並びで以下のように表示されます。

リストの表示
リストの表示

タスクデータの表示

タスクデータはlistsデータプロパティの各listデータの中に含まれているので、先ほど展開したlistのデータプロパティtasksをv-forで展開します。タスク名とユーザ名はすべてのtaskデータに含まれていますが、descriptionが含まれていないものがあるためdescriptionがある場合のみv-showで表示させています。


<div id="trello" class="flex m-10">
  <div 
    v-for="(list,index) in lists" 
    :key="index" 
    style="min-width:260px"
    class="m-2 bg-gray-200 rounded-lg h-full"
  >
    <div class="font-bold text-sm p-4">{{ list.name }}</div>
    <div class="mx-2 mb-8">
      <div v-for="(task,index) in list.tasks" 
        :key="index"
        class="mb-3 p-2 bg-white shadow text-xs rounded w-60">
        <div>
          <div class="font-bold mb-2">
            {{ task.name}}
          </div>
          <div v-show="task.description">
            {{ task.description}}
          </div>
          <div class="text-xs flex justify-end">
            {{ task.user_name }}
          </div>
        </div>
      </div>
    </div>
  </div>
</div>

ブラウザで確認すると下記のように表示されます。

タスクの表示
タスクの表示

これでリストとタスクの表示は完了で、ここからJavaScriptを使って、ドラッグ&ドロップを実装していきます。

mouseイベント

本アプリケーションでは、mouseイベントの中の以下の3つのイベントを利用します。

  • mousedownイベント
  • mouseupイベント
  • mousemoveイベント

それぞれのmouseイベントを確認していきましょう。

mousedownイベント

mousedownイベントはマウスの右クリックを押すとイベントが発火します。Vue.jsでは要素にmousedownイベントを設定する場合は@mousedownと記述します。タスク要素にmousedownイベントを設定し、mousedownイベントが発火した際(タスク要素上でマウスの右クリックを押す)にmouseDownメソッドが実行されます。


<div v-for="(task,index) in list.tasks" 
  :key="index"
  class="mb-3 p-2 bg-white shadow text-xs rounded w-60"
  @mousedown="mouseDown" //追加
  >

mouseDownイベントをVue.jsのmethodsに追加します。


const app = Vue.createApp({
  data(){
    //略
    }
  },
  methods:{
    mouseDown(){
      console.log('マウスダウンイベント')
    }
  }
}).mount('#app')

設定後、どのタスク要素をクリックしてもブラウザのデベロッパーツールのコンソールに”マウスダウンイベント”のメッセージが表示されます。

mousemoveイベント

mousemoveイベントはmouseが動いている間ずっとイベントが発火します。

ブラウザ上どこにマウスをおいてもmousemoveイベントが発火されるようにある特定の要素にイベントを設定するのではなくwindowオブジェクトにイベントリスナーを設定してmousemoveイベントを設定します。

Vueインスタンスの起動時のライフサイクルフックmounted上でイベントリスナーへの追加を行います。mousemoveイベントが発火されるとmouseMoveメソッドが実行されます。


const app = Vue.createApp({
  data(){
    //略
    }
  },
  methods:{
    //略
  },
  mounted() {
    window.addEventListener('mousemove', this.mouseMove);
  }
}).mount('#app')

mouseMoveメソッドを追加します。


methods:{
  mouseDown(){
    console.log('マウスダウンイベント')
  },
  mouseMove(){
    console.log('マウスムーブイベント')
  }
},

mousemoveイベントについてはブラウザ上にマウスを動かすだけでイベントが発火するのでコンソールに”マウスムーブイベント”が繰り返し表示されます。

mouseupイベント

mouseupイベントはクリックしたボタンを離した時にイベントが発火します。mouseupイベントもmousemoveイベントと同様にブラウザ全体でイベントを検知するためにmountedフックでイベントリスナーを追加します。


mounted() {
  window.addEventListener('mousemove', this.mouseMove);
  window.addEventListener('mouseup', this.mouseUp);
}

mouseupイベントが発火した時はmouseUpメソッドが実行されます。mouseUpメソッドもこれまでのmouseイベントと同様にmethodsに追加します。


methods:{
  mouseDown(){
    console.log('マウスダウンイベント')
  },
  mouseMove(){
    console.log('マウスムーブイベント')
  },
  mouseMove() {
    console.log('マウスアップイベント')
  }
},

タスク要素の上でクリックするとマウスダウンイベントとマウスアップイベントがコンソールに一度に表示されます。タスク要素の外側でクリックするとマウスアップイベントのみコンソールに表示されます。

mouseUpイベントはマウスのクリックを押し続けている場合は発火しません。

ライフサイクルフックmountedでイベントリスナーに追加を行った場合はライフサイクルフックbeforeunMountでイベントリスナーから削除します。


mounted() {
  window.addEventListener('mousemove', this.mouseMove);
  window.addEventListener('mouseup', this.mouseUp);
},
beforeunMount(){
  window.removeEventListener('mousemove', this.mouseMove);
  window.removeEventListener('mouseup', this.mouseUp);
}
本アプリケーションはindex.htmlファイルのみを利用しコンポーネントも利用しません。そのためbeforeUnmountの設定は必須ではありません。コンポーネントを利用している場合は忘れずに削除の設定を行ってください。

マウスの動きとタスク要素を同期

3つのmouseイベントを理解することができたので、次はマウスに合わせてタスク要素を移動させます。

マウスの位置を表示

マウスがどこの場所にいるのかイベントを使って確認することができます。mousemoveイベントを利用することで現在のマウスの位置情報を取得することができます。eventのpageXでブラウザの左上からのX軸の値、pageYでY軸の値を取得することができます。


mouseMove(){
  console.log(event.pageX, event.pageY)
},

pageXとpageYの位置はブラウザの左上が0,0となり数値は正の数となります。右へ行けばpageXの値は増え、下にいくとpageYの値が増えます。

pageXとpageYの位置
pageXとpageYの位置

マウスを動かすとデベロッパーツールのコンソールに位置(pageX, pageY)が表示されます。

マウスの位置の表示
マウスの位置の表示

クリックした要素を動かす

eventのpageXとpageYでマウスの位置をリアルタイムで取得することがわかったのでマウスに合わせてタスクの要素を移動させます。

今回アプリケーションでは利用しないdraggable属性をタスク要素に設定するとドラッグが可能となり、マウスと一緒に要素を動かすことは可能です。どういうものかだけ参考に動作確認しておきます。タスクの要素にdraggable属性を追加し、trueと設定します。


<div v-for="(task,index) in list.tasks" 
  :key="index"
  class="mb-3 p-2 bg-white shadow text-xs rounded w-60"
  @mousedown="mouseDown"
  draggable="true"
  >

タスク要素上でマウスのボタンを押すと押している最中はタスク要素を移動させることができます。

draggableの設定の確認
draggableの設定の確認

本題のmouseイベントを利用してタスク要素の移動を行います。

mouseDownイベントを利用してクリックした要素の情報を取得します。


mouseDown(){
  console.log(event.target)
  console.log('マウスダウンイベント')
},

タスクをクリックするとコンソールにクリックしたタスク要素を構成する個別要素の情報が表示されます。

タスク要素の中の要素情報を取得
タスク要素の中の要素情報を取得

欲しい情報はタスク要素全体なので、内部の要素がイベントに反応しないようにpointer-events-noneクラスを設定します。


<div class="pointer-events-none">
  <div class="font-bold mb-2">
    {{ task.name}}
  </div>
  <div v-show="task.description">
    [{{ task.description}}
  </div>
  <div class="text-xs flex justify-end">
    {{ task.user_name }}
  </div>
</div>

pointer-events-noneクラスを追加後、再度タスク要素をクリックするとタスク要素全体の情報を取得することができます。

タスク要素全体の情報を取得
タスク要素全体の情報を取得

要素の情報が取得できたのでクリックした要素のpositionをabsoluteに設定し、マウスの位置を設定します。要素の位置はtopとleftプロパティで設定することが可能でtopにマウスのpageX、leftにpageYを設定します。


mouseDown(){
  event.target.style.position = "absolute"
  event.target.style.top = `${event.pageX}px`
  event.target.style.left = `${event.pageY}px`

  console.log('マウスダウンイベント')
},

レポート作成タスクの左上をクリックするとpositionをabsoluteに設定したので元いた場所の領域を開放し、topはpageX, leftはpageYに設定されるので下記の位置に表示されます。

top、leftプロパティの設定
top、leftプロパティの設定
親要素にrelativeを設定していないためブラウザの左上が基準点になります。

style属性のposition, top, leftを使って要素の位置を変更できることがわかったのでmousemoveイベントと連携してタスク要素を動かします。

要素がドラッグされているかどうかを状態を保持するデータプロパティdraggingとクリックした要素の情報を保持するデータプロパティelementを追加します。


data(){
  return {
    element:'',
    dragging:false,

mouseDownイベントではdraggingをデフォルトのfalseからtrueにしてドラッグが行われている状態に変更します。マウスを押している状態ではdraggingはtrueですが、マウスのクリックボタンから指を離すとmouseUpメソッドでdraggingはfalseにします。mouseMoveメソッドではdraggingがtrue(ドラッグ状態)の場合のみマウスの位置情報をタスクのtopとleftに設定します。


methods:{
  mouseDown(){
    this.dragging = true;
    this.element = event.target;
    this.element.style.position = "absolute";
    console.log('マウスダウンイベント')
  },
  mouseMove(){
    if(this.dragging){
      this.element.style.top = `${event.pageY}px`
      this.element.style.left = `${event.pageX}px`
    }
  },
  mouseUp() {
    this.dragging = false;
    console.log('マウスアップイベント')
  }
},

ここまでの設定でタスク要素をマウスの動きに合わせて自由に動かせるようになりました。

マウスに合わせてドラッグ&ドロップ
マウスに合わせてドラッグ&ドロップ

タスク要素クリックした際に文字列を選択してしまう場合があるので文字列を選択できないようにリスト要素にselect-noneクラスを設定します。


<div
  v-for="(list,index) in lists" 
  :key="index" 
  style="min-width:260px"
  class="m-2 bg-gray-200 rounded-lg h-full select-none"
>

タスクの要素を移動することができましたが、タスク領域をクリックしマウスを動かした瞬間にマウスポインターにタスク領域の左上が移動してきます。それはタスク領域のtopにマウスの位置のpageY, leftにpageXが設定されるためです。

マウスの移動した距離をタスクの移動に反映されるようにマウスをクリックした時の位置情報を保存します。保存した位置情報と動いた位置の情報の差をとることで移動したX、Yの値を取得します。


data(){
  return {
    element:'',
    dragging:false,
    pageX:0,
    pageY:0,

mouseDownメソッドでクリックした時のマウスの位置をthis.pageXとthis.pageYに保存します。mouseMoveメソッドの中でドラッグしているマウスの位置とマウスをクリックした位置の差を取得し、その差をタスク要素のtopとleftに設定します。


methods:{
  mouseDown(){
    this.dragging = true;
    this.element = event.target;
    this.element.style.position = "absolute";
    this.pageX = event.pageX;
    this.pageY = event.pageY;
    console.log('マウスダウンイベント')
  },
  mouseMove(){
    if(this.dragging){
      let moveX = event.pageX - this.pageX;
      let moveY = event.pageY - this.pageY;
      this.element.style.top = `${moveY}px`
      this.element.style.left = `${moveX}px`
    }
  },

動作確認するためにタスク要素をクリックするとマウスポインターの位置に移動してくることはなくなりましたが、ブラウザの左端にクリックしたタスク領域が移動します。その後はマウスに合わせて移動します。

タスク要素の移動
タスク要素の移動

タスク要素の移動の開始位置を正しい位置にするためクリック時の各タスク要素のtopとleftを保存します。


data(){
  return {
    //略
    top:0,
    left:0,

mousedownイベントのmouseDownメソッドでクリックしたタスクのtopとleftを保存します。topとleftの値はgetBoundingClientRect()メソッドで取得することができます。


methods:{
  mouseDown(){
    //略
    console.log(this.element.getBoundingClientRect())

getBoundingClientRect()メソッドでは、要素のtop, leftの位置だけではなく幅や高さも取得することができます。

要素の各種情報を取得
要素の各種情報を取得

クリック時のtopとleftを利用してドラッグしている要素の位置情報を下記のように設定し更新していきます。


mouseDown(){
  this.dragging = true;
  this.element = event.target;
  this.element.style.position = "absolute";
  this.pageX = event.pageX;
  this.pageY = event.pageY;
  this.top = this.element.getBoundingClientRect().top;
  this.left = this.element.getBoundingClientRect().left;
  console.log('マウスダウンイベント')
},
mouseMove(){
  if(this.dragging){
    let moveX = event.pageX - this.pageX;
    let moveY = event.pageY - this.pageY;
    this.element.style.top = `${this.top + moveY}px`
    this.element.style.left = `${this.left + moveX}px`
  }
},

動作確認を行うとクリックした位置から要素の移動を行えるようになりました。

親要素を基準点にしたタスク要素のドラッグ
親要素を基準点にしたタスク要素のドラッグ

Placeholder(ダミー要素)の追加

今回のTrelloクローンのポイントの一つであるドラッグした要素の位置にPlaceholder(ダミー領域)要素を追加する処理を行います。ここまでの設定ではタスク領域をクリックするとpositionがabsoluteになるため元の位置で確保していた領域が開放されます。この開放した領域にPlaceholder要素を追加します。

Placeholderの情報を保持するデータプロパティplaceHolderを追加します。


data(){
  return {
    //略
    placeHolder:'',

mousemoveイベントでタスク要素が動き出した時にPlaceholder要素の追加を行います。createElementメソッドでdiv要素を作成しドラッグしたタスク要素の下(nextSibling)に作成したdiv要素をinsertBeforeメソッドで追加します。classList.addでdiv要素にクラスの追加も行っています。


mouseDown(){
  this.dragging = true;
  this.element = event.target;
  this.element.style.position = "absolute";
  this.pageX = event.pageX;
  this.pageY = event.pageY;
  this.top = this.element.getBoundingClientRect().top;
  this.left = this.element.getBoundingClientRect().left;
  console.log('マウスダウンイベント')
},
mouseMove(){
  if(this.dragging){
    let moveX = event.pageX - this.pageX;
    let moveY = event.pageY - this.pageY;
    this.element.style.top = `${this.top + moveY}px`
    this.element.style.left = `${this.left + moveX}px`
//placeholderの作成と追加
    this.placeHolder = document.createElement("div");
    this.placeHolder.classList.add("mb-3", "p-2", "bg-gray-300");
    this.element.parentNode.insertBefore(this.placeHolder, this.element.nextSibling);
  }
},

上記の設定でPlaceHolder要素の追加を行うことができましたが2つの問題が確認できます。一つはドラッグする度にPlaceHolderが増え続けること、もう一つはPlaceHolderの高さがドラッグしたタスク要素の高さと異なることです。

PlaceHolder要素の追加
PlaceHolder要素の追加

PlaceHolder作成の制御

ドラッグを開始した最初の一度だけ処理を実行するためデータプロパティfirstを追加します。


data(){
  return {
    //略
    first:true,

mouseDownメソッドでfirstをtrueにし、moseMoveメソッドの中でPlaceHolderの処理が完了したらfalseにします。


  mouseDown(){
    //略
    this.first = true;
  },
mouseMove(){
  if(this.dragging){
    if(this.first){
      this.placeHolder = document.createElement("div");
      this.placeHolder.classList.add("mb-3", "p-2", "bg-gray-300");
      this.element.parentNode.insertBefore(this.placeHolder, this.element.nextSibling);
      this.first = false;
    }
    let moveX = event.pageX - this.pageX;
    let moveY = event.pageY - this.pageY;
    this.element.style.top = `${this.top + moveY}px`
    this.element.style.left = `${this.left + moveX}px`
  }
},

この設定によりPlaceHolder要素が複数作成される問題は解消されます。

PlaceHolderの高さの問題解消

高さの問題はデータプロパティheightを追加してタスク要素の高さを保存し、div要素を追加する際に保存した高さをdiv要素に反映させます。


data(){
  return {
    //略
    top:0,
    left:0,
    height:0,

mouseDownメソッドの中でtop, leftと同様にgetBoundingClientRectメソッドからタスク要素の高さheightを取得します。


methods:{
  mouseDown(){
    //略
    this.top = this.element.getBoundingClientRect().top;
    this.left = this.element.getBoundingClientRect().left;
    this.height = this.element.getBoundingClientRect().height;
    this.first = true;
  },

mouseMoveメソッドの中createElementメソッドで作成したdiv要素にstyle.heightで高さを設定します。


mouseMove(){
  if(this.dragging){
    if(this.first){
      this.placeHolder = document.createElement("div");
      this.placeHolder.style.height = `${this.height}px`
      this.placeHolder.classList.add("mb-3", "p-2", "bg-gray-300");
      this.element.parentNode.insertBefore(this.placeHolder, this.element.nextSibling);
      this.first = false;
    }
    //略

設定後はドラッグした要素の高さに合わせたPlaceHolderが作成できることが確認できます。

PlaceHolderの高さを確保
PlaceHolderの高さを確保

ドラッグ要素の複製

ドラッグしたタスク要素のpositionをabsoluteに設定しそのタスク要素をドラッグしていましたがドラッグ要素をそのまま利用するのではなく複製を行い、id=”app”のdivの閉じタグの手前に複製したドラッグ要素を挿入します。

id=”app”のdivの閉じタグの前にVue.jsから直接要素にアクセスできるようにref属性を追加しdragを設定します。


    <div ref="drag"></div>
  </div>
</body>
</html>

新たにドラッグ要素用の情報を保存するデータプロパティdragElementを追加します。


data(){
  return {
    //略
   dragElement:'',
   placeHolder:'',

複製処理はdragMoveメソッドの中で行い、cloneNodeメソッドを利用します。

クリックしたタスク要素をcloneNodeで複製した後タスク要素はdispaly:noneで非表示にします。複製したドラッグ要素のpositionにabsoluteを設定し、ref属性で指定したdivにappendChildメソッドで複製したドラッグ要素を追加します。ドラッグ要素の位置は保存しておいたタスク領域の場所top, leftを設定し、transformで12度傾けます。


mouseMove(){
  if(this.dragging){
    if(this.first){
      this.placeHolder = document.createElement("div");
      this.placeHolder.style.height = `${this.height}px`
      this.placeHolder.classList.add("mb-3", "p-2", "bg-gray-300");
      this.element.parentNode.insertBefore(this.placeHolder, this.element.nextSibling);
//ドラッグ要素の複製の実施とdiv要素への追加
      this.dragElement = this.element.cloneNode(true);
      this.element.style.display = "none";
      this.dragElement.style.position = "absolute";
      this.$refs.drag.appendChild(this.dragElement)
      this.dragElement.style.top = `${this.top}px`;
      this.dragElement.style.left = `${this.left}px`;
      this.dragElement.classList.add("transform", "rotate-12")
      this.first = false;
    }

上記以外にも2点更新が必要です。

mouseDownメソッドでタスク要素に対して設定していたposition設定を削除します。


      mouseDown(){
        this.dragging = true;
        this.element = event.target;
        this.element.style.position = "absolute"; //必要ないので削除
      },

mouseMoveメソッドでマウスの位置を設定していた要素をタスク領域から複製したドラッグ要素に変更します。


mouseMove(){
  if(this.dragging){
    if(this.first){
      //略
    }
    let moveX = event.pageX - this.pageX;
    let moveY = event.pageY - this.pageY;
    this.dragElement.style.top = `${this.top + moveY}px`
    this.dragElement.style.left = `${this.left + moveX}px`
  }
},

動作確認を行うとクリックした要素をドラッグを開始すると傾いたままドラッグできるようになります。実際にドラッグしている要素はクリックしたタスク要素ではなく複製したタスク要素ですが、表面的にはわからず傾きがある以外の違いはわかりません。

タスク要素のドラッグの確認
タスク要素のドラッグの確認

PlaceHolderの場所に戻す

ドラッグした要素はマウスのクリックボタンを外すと外した場所で停止しそれ以後はドラッグすることはできません。クリックボタンを外した場所に停止するのではなくPlaceHolderがある場所に戻るように設定を行います。

マウスのクリックボタンを外した時の処理についてはmouseupイベントに設定したmouseUpメソッドを利用します。

mouseUpメソッドの中ではPlaceHolder要素とドラッグ要素を削除し、displayのnoneで非表示にしていたタスク要素をnoneからblockに変更し表示させます。


mouseUp() {
  this.placeHolder.remove()
  this.dragElement.remove();
  this.element.style.display = "block";
  this.dragging = false;
  console.log('マウスアップイベント')
}

クリックを押しながらドラッグしクリックから指を離すとPlaceHolderの場所に戻っていきます。

PlaceHolderの場所に戻る
PlaceHolderの場所に戻る

並び替え機能の実装

ここからがtrelloクローンでも最も重要なタスク要素の並び替えの機能の実装です。

タスク要素の並び替えには同じリスト内で行う方法と他のリストのタスクと並び替えを行う場合の2通りがあります。まずは並び替えを処理を理解するために1つのリストのみ利用して行います。

idの2と3を持つリストをコメントとして下記のように一つのリストのみ表示させてください。

リストを1つのみ表示
リストを1つのみ表示

最も近いタスクを見つける

リストの2番目のタスク(業界の調査)に注目して最も近いタスクがどれなのかを確認していきます。最も近いタスクを見つけるのはその要素が並び替えの候補となる要素だからです。

タスク要素をクリックし、クリックした位置とリスト上の各タスクの中心の距離を比較してタスク上にあるマウスポインターの位置からの距離を計算します。

各タスクの位置を確認するためにはタスク要素一覧を取得する必要があります。タスクの情報を取得する前にタスク要素を含むリストの要素をquerySelectorAllを利用して取得します。


mouseMove(){
  //略
  this.dragElement.style.left = `${this.left + moveX}px`

  let lists = document.querySelectorAll('.list')
 console.log(lists)

querySelectAllに指定しているlistクラスをリストのdivタグに追加します。


<div 
  v-for="(list,index) in lists" 
  :key="index" 
  style="min-width:260px"
  class="m-2 bg-gray-200 rounded-lg h-full select-none list" 
>

設定後、タスクをドラッグするとコンソールにはリストの情報が表示されます。リストの表示を一つにしているため一つのリストのみ表示されます。


NodeList [div.m-2.bg-gray-200.rounded-lg.h-full.select-none.list]
0: div.m-2.bg-gray-200.rounded-lg.h-full.select-none.list
length: 1
__proto__: NodeList

forEachを利用してlistsを展開し、今度はタスク情報を取得します。再度querySelectorAllを利用し、タスク情報を取得するのでタスク要素のタグにsortableクラスを追加します。


<div v-for="(task,index) in list.tasks" 
  :key="index"
  class="mb-3 p-2 bg-white shadow text-xs rounded w-60 select-none sortable"
  @mousedown="mouseDown"
  >

クリックしたタスク要素はdisplayのnoneで非表示に設定しているのでfilterを利用してその要素を除いたリスト上のタスク要素を取得します。…(スプレッド演算子)を利用して配列にしてfilter関数を実行しています。


let lists = document.querySelectorAll('.list')
lists.forEach(list => {
  let sortableTasks = [...list.querySelectorAll('.sortable')].filter(task => task.style.display !== "none")
 console.log(sortableTasks);
})

クリックしたタスク要素を除いた4つのタスク要素を取得することができます。

取得したタスク情報
取得したタスク要素

sortableTasksに保存されている要素毎にgetBoundingClientRectメソッドを利用してtopとheightを利用することでタスク要素の中心のyの値を計算しています。その値とマウスのpageYの値を比較して距離(offsetY)を出しています。


let sortableTasks = [...list.querySelectorAll('.sortable')].filter(task => task.style.display !== "none")
sortableTasks.forEach(task => {
  let taskBox = task.getBoundingClientRect()
  let offsetY = event.pageY - (taskBox.top + taskBox.height / 2)
  console.log(offsetY,task)
})

各要素とのoffsetYは下記のようになります。一番上のタスクとのoffsetYの値は84で下のタスク要素とのoffsetYは-68です。ドラッグした要素は2番目の要素です。

クリックしたタスク要素と他の要素との距離
クリックしたタスク要素と他の要素との距離

図を使って説明するとクリックした”業界の調査”より上にある”レポートの作成”とのoffsetYの正の数値になり、それ以下の要素については負の数値となります。

offsetYの図
offsetYの図

offsetYの値が小さいものが最も近いタスクになりますが、+(プラス)の場合と-(マイナス)の場合の2つのパターンがあります。本アプリケーションではマイナスの値のみチェックし、プラスの値は無視します。その理由についてこれから説明していきます。

”業界の調査”のタスク要素をクリックして、下に移動させます。マイナスのoffsetYだけチェックしているので左の図では”業界の調査”から見ると”ウェビナーの開催”が最も近いタスクです。”業界の調査”のマウスの位置が”ウェビナーの開催”の真ん中を超えると並び替えが発生します。その際に”業界の調査”のPlaceHoloderは”メルマガの送信”タスクの前に挿入され、並び替え後は”メルマガの送信”が最も近いタスクになります。つまり最も近いタスクの前にPlaceHolderを入れることで並び替えが実現できます。

タスク要素の並び替え
タスク要素の並び替え

まだなぜ最も近いタスクの前なのと疑問に思う人もいると思うので、さらに”業界の調査”の要素を下に下げていきます。”メルマガの送信”の中央をマウスの位置が超えると並び替えが発生します。並び替えが発生した後の最も近いタスクは”社内トレーニング”となります。社内トレーニングの最も近いタスクの前にPlaceHolderを入れることになります。

では上に移動した場合を考えてみましょう。

左側の図では最も近い”ウェビナー開催”の上にPlaceHolderが挿入されています。タスク要素をドラッグして”レポートの作成”の中央を超えると並び替えが発生し、最も近い要素が”レポートの作成”となり、その上にPlaceHolderが挿入されます。

上のタスク要素と並び替え発生
上のタスク要素と並び替え発生

上下どちらの移動でもoffsetYのマイナスの値が小さな値を持つタスク要素の前に必ずPlaceHolderが存在すればいいことになります。つまりドラッグ要素のマウスの位置より下でoffsetYのマイナスが小さな要素を見つけ出すことができれば並び替えが実装できることになります。

社内トレーニング以下にドラッグした場合はその下に要素が存在しないのでマイナスの値を持つタスクはありません。最も近い要素が存在しない場合は、最も近いタスクの前にPlaceHolderを挿入するのではなくリストの一番下にPlaceHolderを挿入すればいいことになります。

最も近い要素を見つけるコード

offsetYを確認するために先ほどはmap関数を利用しましたが、reduce関数に変更を行います。reduce関数の初期値のoffsetYにはマイナスの無限大を設定しています。offsetYがマイナスの要素があればoffsetYとelementを持つオブジェクトを戻します。タスク分比較を行いながら、offsetYの小さい要素を見つけています。もしoffsetYがマイナスのものがない場合(リストの一番下にドラッグ要素がいった場合)は何も要素は戻しません。この関数では並び替えが行われているかどうかのチェックを行うものではなく最も近い要素を戻すか何も値を戻さないかのどちらかです。ドラッグ要素の下にある要素を見つけるということでbelowElementという名前の変数に保存します。


let belowElement = sortableTasks.reduce((closestElement, sortableTask) => {
  let taskElementBox = sortableTask.getBoundingClientRect()
  let offsetY = event.pageY - (taskElementBox.top + taskElementBox.height / 2)
  if (offsetY < 0 && offsetY > closestElement.offsetY) {
    return {
      offsetY: offsetY,
      element: sortableTask
    }
  } else {
    return closestElement;
  }
}, { offsetY: Number.NEGATIVE_INFINITY }).element

最も近い要素を見つける関数を実行後、最も近い要素の前にPlaceHolderを挿入するので(最も近い要素がない場合はリストの一番したに追加)PlaceHolderを削除して、挿入を行います。

ここまでの設定ではドラッグを行い、mouseMoveイベントが発生するたびに要素の削除と追加が行われます。同じ処理を何度も行わないように制御する必要があります。

this.placeHolder.remove()
this.placeHolder = document.createElement("div");
this.placeHolder.style.height = `${this.height}px`
this.placeHolder.classList.add("mb-3", "p-2", "bg-gray-300");
if (belowElement == undefined) {
  list.children[1].appendChild(this.placeHolder)
} else {
  list.children[1].insertBefore(this.placeHolder, belowElement)
}

belowElementに要素が入っている場合はinsertBeforeメソッドでbelowElement(最も近い要素)の上に挿入を行っています。bewloElementがない場合はリストの一番下にappendChildメソッドで追加しています。

動作確認すると並び替えが行われていることが確認できます。上にドラッグしても下にドラッグしても適切な場所にPlaceHolderが表示されます。

並び替えの動作確認
並び替えの動作確認

並び替えの後の状態を保存

ここまでの設定では、ドラッグを止めると元の状態に戻ります。並び替えの状態を保存するためにmouseupイベントのmouseUpメソッドを更新します。

どの要素がドラッグされ、どのカテゴリーのどのタスクの位置に並び替え後に挿入されるのかわかるようにドラッグ要素のtask, listと並び替えを行うtask,listのIDを保存しておく必要があります。

どのリストでドロップ処理が行われたかを把握するためリスト要素にdata属性を追加します。


<div 
  v-for="(list,index) in lists" 
  :key="index" 
  style="min-width:260px"
  class="m-2 bg-gray-200 rounded-lg h-full select-none list" 
  :data-list-id="list.id"
>

タスク要素にもdata属性を追加します。mouseDownメソッドでtaskとlist情報を保存するので引数taskとlistを設定しておきます。


<div v-for="(task,index) in list.tasks" 
  :key="index" 
  class="mb-3 p-2 bg-white shadow relative text-xs rounded w-60 draggable"
  :data-task-id="task.id"
  @mousedown="mouseDown(task, list)" 
>

データプロパティtask, list, listId, taskIdを追加します。


data(){
  return {
//略
    list:'',
    listId:'',
    task:'',
    taskId:'',

mouseMoveの中で各要素からtaskIdとlistIdの値を取得します。listIdは必ず取得することができすが、taskIdについては最も近い要素(belowElement)がない場合は要素が取得できないため値をnullとします。


this.listId = parseInt(list.dataset.listId);

this.placeHolder.remove()
this.placeHolder = document.createElement("div");
this.placeHolder.style.height = `${this.height}px`
this.placeHolder.classList.add("mb-3", "p-2", "bg-gray-300");

if (belowElement == undefined) {
  list.children[1].appendChild(this.placeHolder)
  this.taskId = null
} else {
  list.children[1].insertBefore(this.placeHolder, belowElement)
  this.taskId = parseInt(belowElement.dataset.taskId);
}

ドラッグ要素のtask, listの情報はmouseDownメソッドで保存します。


mouseDown(task, list) {
  this.dragging = true;
  this.task = task;
  this.list = list;
  this.pageX = event.pageX;
  this.pageY = event.pageY;
  this.element = event.target;
  this.top = this.element.getBoundingClientRect().top;
  this.left = this.element.getBoundingClientRect().left;
  this.height = this.element.getBoundingClientRect().height;
  this.first = true;
},

mouseUpイベントではリストの中からドラッグしている要素を削除します。

listIdを利用して並び替えが行われたリスト情報を取得します。そのリスト情報のタスクの中に並び替えを行ったタスクが存在するのでそのタスクがある配列のIndexをtaskIdを利用して取得します。そのIndexを利用してドラッグした要素を挿入します。

taskIdがnullの場合はpushメソッドでリストの一番後ろに追加を行います。

並び替えが終わった後はplaceHolderとdragElementの要素をremoveメソッドで削除します。


mouseUp() {
  if (this.dragging && !this.first) {
    let deleteIndex;
    this.list.tasks.map((task, index) => { if (task.id === this.task.id) deleteIndex = index })
    this.list.tasks.splice(deleteIndex, 1)

    let list = this.lists.find(list => list.id === this.listId);
    if (this.taskId !== null) {
      let addIndex;
      list.tasks.map((task, index) => { if (task.id === this.taskId) addIndex = index })
      list.tasks.splice(addIndex, 0, this.task)
    } else {
      list.tasks.push(this.task)
    }
  this.placeHolder.remove()
  this.dragElement.remove();
  this.element.style.display = "block";
  }
  this.dragging = false;
}

ここまでの設定で動作確認を行います。リストが一つなのでまだ完成ではありませんが、タスクの並び替を行うことができ、PlaceHolderも期待通りに表示されます。

並び替えの動作確認
並び替えの動作確認

リストが1つの場合の動作確認は完了しました。次回は複数のリストの場合の設定について次回の文書で説明を行います。