DnD で順番や階層を変えられる木構造 Todo の作り方

Backlog 開発チームの saki です。最近、Web フロントエンドの技術を使い、タスクの順番や階層をドラッグアンドドロップ(DnD)で変えられる、木構造 Todo の習作をしました。後学のために作り方の主要な部分を紹介します。また、DnD 周りではまった問題と解消法を共有します。

作った Todo は以下 gif のようなものです。基本的な Todo 機能(タスクの追加、完了、削除)に加えて、DnD で順番や階層を変えられたり、タスクの開閉状態を切り替えて子タスクを表示非表示できます。DnD でタスクを意図した位置に移動できるよう、タスクや階層の間にはスペーサーを設けています。

記事内のコードには TypeScript 4.6、UI ライブラリには Preact 10 系を用います。前提として、コードは実装を一部省略して記載します。そのままでは動かないため注意してください。動作するコードの全容については GitHub リポジトリ をご覧ください。また、習作のデモは https://dnd-tree-todo.vercel.app/ で公開しています。

記事のアウトラインは以下です。

  • 木構造 Todo のデータ表現
  • ビューの作成
  • ツリー更新処理の作成
  • ドロップ可否判定の作成
  • DnD イベントに対する実装
  • dragenter 時のスタイル更新がちらつく問題の解消法

木構造 Todo のデータ表現

はじめに、木構造 Todo のデータを扱うための型を用意します。children に TreeNode の配列が入るようにして、入れ子になった TreeNode を扱えるようにします。

export type TreeNode = {
  id: string
  name: string
  isCompleted: boolean
  isOpen: boolean
  children: TreeNode[]
}

ビューの作成

次に、木構造データ(入れ子になった TreeNode)を表示するためのビューを作ります。ビューを構成するコンポーネントを考えつつ、階層構造で並べて仕様を検討しましょう。アプリ全体を App、タスクを木構造で表示する箇所を TreeNodeList、1タスクに対応するものを TreeNodeItem、タスクや階層の間の余白を TreeSpacer とすると、ビューは以下のように表せます。

  • App
    • TreeNodeList
      • TreeSpacer
      • TreeNodeItem
      • TreeSpacer
      • TreeNodeItem
        • TreeSpacer
        • TreeNodeItem
          • TreeSpacer
          • TreeNodeItem
          • TreeSpacer
        • TreeSpacer
      • TreeSpacer
      • TreeNodeItem
      • TreeSpacer

これをもとにビューの要件を整理すると、

  • TreeNodeList コンポーネント内で TreeNodeItem のリストを表示する
  • ただし、リストの先端と終端、TreeNodeItem 同士の間には余白を表す TreeSpacer を入れる
  • TreeNode が children を持たない場合、TreeNodeItem をそのまま表示する
  • TreeNode が children を持つ場合、TreeNodeItem を表示しつつ、開閉状態(isOpen)に合わせてさらに children の TreeNode[] をもとに、TreeNodeItem と TreeSpacer からなるリストを表示する

と言えそうです。これらをふまえて TreeNodeList コンポーネントを作ります。

export const TreeNodeList = (props: TreeNodeListProps) => {
// 略
  const listItems = (nodes: TreeNode[], itemDepth: number = 0, parentId: string) => (
    nodes.map((node, index, array) =>
      <div>
        <li key={`spacer-above-${node.id}`}>
          <TreeSpacer /> // 略
        </li>
        <li key={`node-${node.id}`}>
          {node.children.length === 0 ?
            <TreeNodeItem /> : // 略
            <div>
              <div>
                <TreeNodeItem /> // 略
              </div>
              {node.isOpen && <ul>{listItems(node.children, itemDepth + 1, node.id)}</ul>}
            </div>
          }
        </li>
        {index === array.length - 1 &&
          <li key={`spacer-below-${node.id}`}>
            <TreeSpacer /> // 略
          </li>
        }
      </div>
    )
  )

  return (<ul>{listItems(treeNodes, depth, rootId)}</ul>)
}

listItems は TreeNode[], itemDepth, parentId を引数に取り、TreeSpacer と TreeNodeItem を交互に表示します。TreeNode が children を持つ場合は itemDepth をインクリメントして listItems を再帰的に呼ぶことで、入れ子になった TreeNode から ビューを描画できるようにしています。なお、itemDepth はタスクの階層に伴うインデント、parentId は DnD イベントで使うために引数に加えています。

これで、木構造 Todo のビューの基礎部分ができました。あとは、TreeNodeItem, TreeSpacer コンポーネント、タスク追加用フォームなどを用意します。これらの実装は単純なため、説明は省略します。

ツリー更新処理の作成

続いて、木構造データを更新するためのツリー更新処理を作ります。Todo で提供する機能を考えて、必要な処理を作りましょう。

機能としては、以下が考えられます。

  1. タスクの追加
  2. タスクの削除
  3. タスクの完了
  4. タスクの開閉状態更新
  5. タスクを DnD で並び替え
    1. ドラッグしたタスクをタスク上(TreeNodeItem)でドロップすると、ドロップ対象の子タスク先頭に移動
    2. ドラッグしたタスクをタスク間の余白上(TreeSpacer)でドロップすると、余白が表現する特定の位置に移動

1-4 については基本的な Todo 機能を木構造データに対して作る話、5 についてはドロップした際にドロップ場所に応じて木構造データを更新する話と言えます。

では、1-4 機能のためのツリー更新処理を作ります。

// タスクの追加に伴うツリー更新
export const addNode = (root: TreeNode, node: TreeNode): TreeNode => {
  return {...root, children: [node, ...root.children]}
}

// タスクの削除に伴うツリー更新
export const deleteNode = (root: TreeNode, nodeId: string): TreeNode => {
  const f = (node: TreeNode) => {
    if (node.children.length >= 1) {
      const idx = node.children.findIndex(n => n.id === nodeId)
      idx >= 0 ? node.children.splice(idx, 1) : node.children.map(c => f(c))
    }
  }
  f(root)
  return root
}

// タスクの完了に伴うツリー更新
export const toggleNodeCompleted = (root: TreeNode, nodeId: string): TreeNode => {
  const f = (node: TreeNode) => {
    if (node.children.length >= 1) {
      const idx = node.children.findIndex(n => n.id === nodeId)
      idx >= 0 ?
        node.children.splice(idx, 1, {...node.children[idx], isCompleted: !node.children[idx].isCompleted}) :
        node.children.map(c => f(c))
    }
  }
  f(root)
  return root
}

// タスクの開閉状態更新に伴うツリー更新
export const toggleNodeOpen = (root: TreeNode, nodeId: string): TreeNode => {
  const f = (node: TreeNode) => {
    if (node.children.length >= 1) {
      const idx = node.children.findIndex(n => n.id === nodeId)
      idx >= 0 ?
        node.children.splice(idx, 1, {...node.children[idx], isOpen: !node.children[idx].isOpen}) :
        node.children.map(c => f(c))
    }
  }
  f(root)
  return root
}

deleteNode, toggleNodeCompleted, toggleNodeOpen では、TreeNode に対し children から更新対象を探して見つかったら更新、なければさらに同様の処理を children に対して実行する関数を作り、再帰的に呼ぶことでツリーを更新しています。

基本的なツリー更新処理ができたので、5 の DnD 並び替えのための処理を作ります。

5-1 のタスク上でドロップする場合は説明の通りですが、5-2 のスペーサー上でドロップする場合に関しては、スペーサーの位置がリスト先端、リスト終端、タスク間の 3 通りあるため、それぞれ分けて作ります。ただ、リスト先端の場合は結局 5-1 と同様の処理と考えられるため、まとめます。

// 5-1 または 5-2 リスト先端の場合のツリー更新
export const moveToFirstChild = (root: TreeNode, targetNodeId: string, parentNodeId: string) => {
  const found = findNode(root, targetNodeId)
  const parent = findNode(root, parentNodeId)
  if (found && parent) {
    deleteNode(root, found.id)
    parent.children.splice(0, 0, found)
  }
  return root
}

// 5-2 リスト終端の場合のツリー更新
export const moveToLastChild = (root: TreeNode, targetNodeId: string, parentNodeId: string) => {
  const found = findNode(root, targetNodeId)
  const parent = findNode(root, parentNodeId)
  if (found && parent) {
    deleteNode(root, found.id)
    parent.children.push(found)
  }
  return root
}

// 5-2 タスク間の場合のツリー更新
export const moveBetweenNodes = (root: TreeNode, targetNodeId: string, parentNodeId: string, nextNodeId: string) => {
  const found = findNode(root, targetNodeId)
  const parent = findNode(root, parentNodeId)
  if (found && parent) {
    deleteNode(root, found.id)
    const idx = parent.children.findIndex(c => c.id === nextNodeId)
    parent.children.splice(idx, 0, found)
  }
  return root
}

// 入れ子になった TreeNode から特定 id の TreeNode を見つける処理
export const findNode = (root: TreeNode, nodeId: string): TreeNode | undefined => {
  const f = (node: TreeNode): TreeNode | undefined => {
    if (root.id === nodeId) {
      return node
    } else if (node.children.length >= 1) {
      const found = node.children.find(n => n.id === nodeId)
      if (found) {
        return found
      } else {
        for (const n of node.children) {
          const result = f(n)
          if (result) {
            return result
          }
        }
      }
    } else {
      return undefined
    }
  }
  return f(root)
}

moveToFirstChild, moveToLastChild は、ツリー全体とドラッグしたタスクの id, ドロップ対象要素の親タスクのid を引数に取り、ツリーから対象の TreeNode を探して DnD 操作後の位置に移動しています。moveBetweenNodes はさらにドロップしたスペーサーの次に並ぶタスクの id も使いツリーを更新します。

ドロップ可否判定の作成

上で作った 5 のツリー更新処理ですが、そのまま実行すると問題となる場合があります。例えば、親タスクを子タスクまたは子タスクと同階層のスペーサーにドロップし、ツリーを更新すると、木構造データの入れ子の紐付きが途中で崩れてしまい、ビューで表示することができません。

崩れてしまう場合のドロップ操作を禁止するため、また、タスクを他要素上でドラッグする際にドロップ可能かフィードバックを表示するために、ドロップ可否判定を行う canDrop を作ります。

const canDrop = (root: TreeNode, draggingNodeId: string, dropTargetNodeId: string): boolean => {
  if (!isParent(root, dropTargetNodeId, draggingNodeId)) {
    return true
  }
  return false
}
const isParent = (root: TreeNode, targetNodeId: string, maybeParentNodeId: string): boolean => {
  const parent = findNode(root, maybeParentNodeId)
  if (parent) {
    const target = findNode(parent, targetNodeId)
    if (target) {
      return true
    }
  }
  return false
}

DnD イベントに対する実装

いよいよ、DnD イベント(Drag and Drop API で定義されている各イベント)に対する処理を作ります。

まずは、App コンポーネント内に状態管理のためのコードを用意します。

export function App() {
  const [draggingId, setDraggingId] = useState<string | null>(null)
  const [treeNode, setTreeNode] = useState<TreeNode>({
    id: 'root',
    name: '',
    isCompleted: false,
    isOpen: true,
    children: [],
  })
  // 略
}

次に、タスクのドラッグを開始したらタスクに応じた TreeNode の id を一時的に保存し、ドラッグ終了したら破棄する処理を dragstart, dragend 用に作ります。

const handleDragStart = (e: JSX.TargetedDragEvent<HTMLDivElement>) => {
  setDraggingId(e.currentTarget.dataset.nodeId || null)
}
  
const handleDragEnd = (e: JSX.TargetedDragEvent<HTMLDivElement>) => {
  setDraggingId(null)
}

続いて、タスクをドラッグし、他要素(TreeNodeItem または TreeSpacer)に被さったり離れたりした場合の処理を dragenter, dragleave 用に作ります。dragenter 時にはドロップ対象要素の dataset からパラメーターを取得しドロップ可否判定を行い、フィードバックを表示するための class を追加します。dragleave 時はその class を削除します。

const handleDragEnter = (e: JSX.TargetedDragEvent<HTMLDivElement>) => {
  const { first, last, parentId, nextId, nodeId, dropTargetType } = e.currentTarget.dataset
  if (draggingId && parentId) {
    if (dropTargetType === 'node') {
      canDrop(treeNode, draggingId, parentId) ?
        e.currentTarget.classList.add('droppable-node') :
        e.currentTarget.classList.add('undroppable-node')
    } else if (dropTargetType === 'spacer') {
      canDrop(treeNode, draggingId, parentId) ?
        e.currentTarget.classList.add('droppable-spacer') :
        e.currentTarget.classList.add('undroppable-spacer')
    }
  }
}

const handleDragLeave = (e: JSX.TargetedDragEvent<HTMLDivElement>) => {
  removeDroppableStyles(e.currentTarget)
}

const removeDroppableStyles = (target: HTMLDivElement) => {
  target.classList.remove('droppable-node')
  target.classList.remove('undroppable-node')
  target.classList.remove('droppable-spacer')
  target.classList.remove('undroppable-spacer')
}

最後に、タスクをドロップする際の処理を drop 用に作ります。ドロップされたらドロップ可否判定を行い、可であればツリー更新処理を実行して、treeNode を更新します。また、drop イベントを起こすには、あらかじめ dragover イベントのデフォルト処理をキャンセルする必要があるため、それも作ります。

const handleDrop = (e: JSX.TargetedDragEvent<HTMLDivElement>) => {
  const {first, last, parentId, nextId, dropTargetType, nodeId} = e.currentTarget.dataset
  if (draggingId && parentId) {
    if (dropTargetType === 'node' && nodeId && canDrop(treeNode, draggingId, nodeId)) {
      setTreeNode({...moveToFirstChild(treeNode, draggingId, nodeId)})
    } else if (dropTargetType === 'spacer') {
      if (first && canDrop(treeNode, draggingId, parentId)) {
        setTreeNode({...moveToFirstChild(treeNode, draggingId, parentId)})
      } else if (last && canDrop(treeNode, draggingId, parentId)) {
        setTreeNode({...moveToLastChild(treeNode, draggingId, parentId)})
      } else if (nextId && canDrop(treeNode, draggingId, nextId)){
        setTreeNode({...moveBetweenNodes(treeNode, draggingId, parentId, nextId)})
      }
    }
  }
  removeDroppableStyles(e.currentTarget)
}

const handleDragOver = (e: JSX.TargetedDragEvent<HTMLDivElement>) => {
  e.preventDefault() // enable drop event
}

これで、DnD イベントに対する処理が揃いました。あとはこれらを TreeNodeItem, TreeSpacer の各イベントと紐づけ、各コンポーネントに必要なパラメーターを埋め込むことで、DnD イベントに応じてツリーを更新できるようになります。

dragenter 時のスタイル更新がちらつく問題の解消法

最初の想定ではここまでで木構造 Todo は完成の予定だったのですが、動作確認するとうまく動かないところがありました。タスクをドラッグして他タスク上に被せる際、ドロップ可否判定後のスタイル更新がちらつく問題が起きていました。

なぜこうなるのか dragenter, dragleave イベントの飛び方を調べてみると、draggable 属性がついた要素で dragenter する際、その子要素でも dragenter, dragleave イベントが飛んでしまうようでした。そのため、意図しないタイミングでスタイルが更新され、ちらついていました。

子要素でのイベントを抑制するため、タスクをドラッグしている間だけ pointer-events: none; のスタイルを適用することにします。これで、意図しないタイミングでのイベントを防ぎ、スタイル更新のちらつきを解消できました。

/* index.css */
.pointer-events-none {pointer-events: none;}
export function App() {
// 略
  return (
    <div>
      <div>
        // 略
        <TreeNodeList
          isDragStarted={draggingId ? true : false}
          // 略
        />
      </div>
    </div>
  )
}
export const TreeNodeItem = (props: TreeNodeItemProps) => {
// 略
  return (
    <div>
      <div style={{ 'width': `${depth * indentRem}rem`}} />
      <div draggable > // 略
        <div class={`${isDragStarted ? 'pointer-events-none' : ''}`}>
          // 略
        </div>
      </div>
    </div>
  )
}

これでようやく完成です。

おわりに

DnD で順番や階層を変えられる木構造 Todo の作り方を紹介しました。木構造で DnD できる UI を作る上で参考になれば幸いです。

 

より良いチームワークを生み出す

チームの創造力を高めるコラボレーションツール

製品をみる