木構造の DnD に適した処理を考える
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