TypeScriptドラッグ&ドロップ発展:ゴースト要素でカンバンボードを実装する

スポンサーリンク

TypeScriptドラッグ&ドロップ発展:ゴースト要素でカンバンボードを実装する

HTML5 Drag and Drop API入門の発展編です。マウスイベント(mousedownmousemovemouseup)を使ってゴースト要素をカーソルに追従させる実装を紹介します。


HTML5 Drag APIとの違い

HTML5 Drag and Drop APIはブラウザがドラッグ画像の描画を担当するため、見た目のカスタマイズに限界があります。マウスイベントで自前実装すると:

  • ゴースト要素がカーソルにぴったり追従する
  • ドラッグ中のスタイルを自由に制御できる
  • canvasSVG 要素も問題なくドラッグできる

HTML構造

<div class="container">
  <div class="list" id="todo">
    <h3>Todo</h3>
    <div class="item">デザインレビュー</div>
    <div class="item">APIの実装</div>
    <div class="item">テストを書く</div>
  </div>
  <div class="list" id="done">
    <h3>Done</h3>
  </div>
</div>

CSS:ドラッグ中のスタイル

.item {
  cursor: grab;
  user-select: none;
}

.list.drag-over {
  border-color: #4c9aff;
  background: #e8f4ff;
}

user-select: none でドラッグ中にテキストが選択されるのを防ぎます。


mousedown:ドラッグ開始とゴースト生成

let dragging: HTMLElement | null = null
let ghost: HTMLElement | null = null
let offsetX = 0
let offsetY = 0

document.querySelectorAll<HTMLElement>('.item').forEach(item => {
  item.addEventListener('mousedown', (e: MouseEvent) => {
    e.preventDefault() // テキスト選択を防ぐ
    dragging = item

    const rect = item.getBoundingClientRect()
    offsetX = e.clientX - rect.left
    offsetY = e.clientY - rect.top

    // 元アイテムのクローンをゴーストとして追加
    ghost = item.cloneNode(true) as HTMLElement
    ghost.style.position = 'fixed'
    ghost.style.width = rect.width + 'px'
    ghost.style.left = rect.left + 'px'
    ghost.style.top = rect.top + 'px'
    ghost.style.margin = '0'
    ghost.style.opacity = '0.85'
    ghost.style.pointerEvents = 'none' // マウスイベントを透過させる
    ghost.style.zIndex = '100'
    document.body.appendChild(ghost)

    item.style.opacity = '0.3' // 元アイテムを半透明に
  })
})

getBoundingClientRect() でアイテムの位置を取得し、クリックした位置からのオフセットを記録します。ゴーストに pointerEvents: none を設定しないと mouseup 時にゴーストが邪魔になります。


mousemove:ゴーストを追従させる

document.addEventListener('mousemove', (e: MouseEvent) => {
  if (!ghost) return

  ghost.style.left = (e.clientX - offsetX) + 'px'
  ghost.style.top = (e.clientY - offsetY) + 'px'

  // ドロップ先をハイライト
  document.querySelectorAll<HTMLElement>('.list').forEach(list => {
    const rect = list.getBoundingClientRect()
    const inside =
      e.clientX >= rect.left && e.clientX <= rect.right &&
      e.clientY >= rect.top && e.clientY <= rect.bottom
    list.classList.toggle('drag-over', inside)
  })
})

elementFromPoint() ではなく座標比較でハイライト判定をしているのは、ゴーストが pointerEvents: none でも elementFromPoint() の結果に影響しないようにするためです。


mouseup:ドロップ処理

document.addEventListener('mouseup', (e: MouseEvent) => {
  if (!dragging || !ghost) return

  // ゴーストを一時的に隠してドロップ先を判定
  ghost.style.visibility = 'hidden'
  const el = document.elementFromPoint(e.clientX, e.clientY)
  const list = el?.closest<HTMLElement>('.list')

  dragging.style.opacity = ''
  if (list) list.appendChild(dragging)

  document.body.removeChild(ghost)
  ghost = null
  dragging = null
  document.querySelectorAll('.list').forEach(l => l.classList.remove('drag-over'))
})

elementFromPoint() 直前にゴーストを visibility: hidden にすることで、ゴーストの下にある要素を正しく取得できます。


TypeScriptの型まとめ

mousedown / mousemove / mouseup のイベント MouseEvent
getBoundingClientRect() の戻り値 DOMRect
cloneNode(true) の戻り値(キャスト必要) HTMLElement
elementFromPoint() の戻り値 Element | null
closest('.list') の戻り値 Element | null

HTML5 Drag APIとの比較

項目 HTML5 Drag API マウスイベント実装
コード量 少ない 多い
ゴースト追従 ブラウザ任せ(カスタム困難) 自前で自由に制御
canvasSVG 動作不安定 問題なし
タッチデバイス △(touch-action 要調整) pointermove に変えれば対応可
向いている場面 シンプルな並び替え リッチなカンバン・エディタ

まとめ

やること コード
ドラッグ開始 mousedown でゴースト生成・オフセット記録
追従 mousemove でゴーストの left/top を更新
ドロップ先判定 ゴーストを visibility: hidden にしてから elementFromPoint()
ドロップ mouseup でゴースト削除・アイテムを移動
  • pointerEvents: none をゴーストに設定しないと mouseup が拾えない
  • elementFromPoint() の前にゴーストを隠すのが判定の要
  • タッチ対応するなら mousedownpointerdownmousemovepointermovemouseuppointerup に置き換える