## 概要
リストAとリストBがあり、それぞれのモデルが`A[]`と`B[]`であるとする。リストAとリストBを相互にドラッグ&ドロップできるようにしたい。ただし、型の安全性は保証すること。
## 戦略
異なるモデル(A VS B)である以上、モデルの変換は必要である。しかしドラッグ&ドロップした後の処理順によっては[[Vue]]の設計が崩れることがある。少なくとも以下の点は非常に重要。
- ドロップ時にドラッグ元のデータ(A)から、ドロップ先のデータ(B)に変換できること
- [[watch (Vue)|watch]]や[[computed()]]がモデル変換後に発生すること
- モデル変換前だとカオスになる
## 検討ライブラリ
[[vue.draggable.next]]を使う。
<div class="link-card">
<div class="link-card-header">
<img src="https://github.githubassets.com/favicons/favicon.svg" class="link-card-site-icon"/>
<span class="link-card-site-name">GitHub</span>
</div>
<div class="link-card-body">
<div class="link-card-content">
<p class="link-card-title">GitHub - SortableJS/vue.draggable.next: Vue 3 compatible drag-and-drop component based on Sortable.js</p>
<p class="link-card-description">Vue 3 compatible drag-and-drop component based o ... </p>
</div>
<img src="https://opengraph.githubassets.com/3ca13d5c899c1855702f0177f7c60c784dfb500e3e367595a5e5cca2e0533beb/SortableJS/vue.draggable.next" class="link-card-image" />
</div>
<a href="https://github.com/SortableJS/vue.draggable.next"></a>
</div>
[[Vue.Draggable]]は[[Vue3]]未対応であり、[[vue.draggable.next]]が[[Vue3]]対応と案内されていた。[[vue.draggable.next]]は数年メンテされていないことから、[[vue-draggable-plus]]の利用も考えたが、まずは[[vue.draggable.next]]で試してみることにした。
## プロジェクト作成
[[Bun]]で[[Vue3]]のプロジェクトを構築。
```console
toki vue vue-draggable-sandbox
```
### インストール
[[vue.draggable.next]]をインストールする。
```console
$ bun add vuedraggable@next
bun add v1.1.25 (fe62a614)
```
### ミニマムのコードを書く
`App.vue`に全部書く。
```html
<script setup lang="ts">
import { ref } from "vue";
import draggable from "vuedraggable";
type Data1 = {
name1: string;
color: string;
};
type Data2 = {
name2: string;
color: string;
};
const models1 = ref<Data1[]>([
{ name1: "user1", color: "red" },
{ name1: "user2", color: "blue" },
{ name1: "user3", color: "green" },
{ name1: "user4", color: "purple" },
{ name1: "user5", color: "yellow" },
{ name1: "user6", color: "black" },
]);
const models2 = ref<Data2[]>([
{ name2: "user101", color: "red" },
{ name2: "user102", color: "blue" },
{ name2: "user103", color: "green" },
{ name2: "user104", color: "purple" },
{ name2: "user105", color: "yellow" },
{ name2: "user106", color: "black" },
]);
</script>
<template>
<div class="draggable-list">
<draggable
v-model="models1"
:animation="150"
group="list"
item-key="name1"
class="item-list"
>
<template #item="{ element }">
<div class="item" :style="{ borderColor: element.color }">
{{ element.name1 }}
</div>
</template>
</draggable>
<draggable
v-model="models2"
:animation="150"
group="list"
item-key="name2"
class="item-list"
>
<template #item="{ element }">
<div class="item" :style="{ borderColor: element.color }">
{{ element.name2 }}
</div>
</template>
</draggable>
</div>
</template>
<style scoped>
.draggable-list {
display: flex;
gap: 16px;
}
.item-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.item {
padding: 8px;
border: solid 4px;
}
</style>
```
同一リストではうまく動くけど、リストが変わると動かなくなる。
![[2024-08-24-16-34-58.webm]]
### watchとcomputedイベントを追加する
[[watch (Vue)|watch]]と[[computed()]]を追加し、どのタイミングで発動するか確かめる。
変更点だけ記載。
```ts
const models1Length = computed(() => {
const v = models1.value.length;
console.log(`[computed] models1Length: ${v}`);
return v;
});
const models2Length = computed(() => {
const v = models2.value.length;
console.log(`[computed] models2Length: ${v}`);
return v;
});
watch(
() => models1.value,
(v) => {
console.log(`[watch] models1.value: ${v.map((x) => x.name1)}`);
},
);
watch(
() => models2.value,
(v) => {
console.log(`[watch] models2.value: ${v.map((x) => x.name2)}`);
},
);
```
[[computed()]]を発動させるため、templateタグの中からも以下のように呼び出す。
```html
<div>models1: {{ models1Length }}</div>
<div>models2: {{ models2Length }}</div>
```
[[Chrome devtools]]には以下のように出力される。
```bash
# 同一リスト内の並び替え (user1を移動)
[watch] models1.value: user2,user3,user4,user1,user5,user6
[computed] models1Length: 6
# 別リストへ移動 (user3を移動 -> 移動先ではname1を読み取れないのでlogはなし)
[watch] models2.value: user101,user102,,user103,user104,user105,user106
[watch] models1.value: user2,user4,user1,user5,user6
[computed] models1Length: 5
[computed] models2Length: 7
```
ここまでは想定通りの動き。
## イベントとwatch/computedの順序を調べる
イベントの実行順序は[[console.log]]の結果から以下のようになる。ただし、あくまで呼び出し順であり、その後の非同期処理順までは保証しない。
```
watch ドロップ先
watch ドロップ元
computed ドロップ先
computed ドロップ元
change
add
sort
```
ここで重要なのは[[Vue]]のモデル変更イベントが常に[[vue.draggable.next]]のイベントより先に着火していることだ。冒頭の戦略では以下のように宣言した。
> - ドロップ時にドラッグ元のデータ(A)から、ドロップ先のデータ(B)に変換できること
> - [[watch (Vue)|watch]]や[[computed()]]がモデル変換後に発生すること
> - モデル変換前だとカオスになる
しかし、実態は逆であり **[[watch (Vue)|watch]]や[[computed()]]がモデル変更後(≒[[vue.draggable.next]]のイベント後)に発生すること** は常に成り立たなそうなのだ。作戦を変える必要がある。
## 一度templateコードを整理する
今のままでは型が不正でも表示がおかしくなるだけなので、瞬間の問題に気づけない。型の違いが発生したら即座に落ちるようにコードを変更する。
```html
<template>
<div>models1: {{ models1Length }}</div>
<div>models2: {{ models2Length }}</div>
<div class="draggable-list">
<draggable
v-model="models1"
:animation="150"
group="list"
item-key="name1"
class="item-list"
>
<template #item="{ element }">
<div class="item" :style="{ borderColor: element.color }">
{{ element.name1 }}
{{ element.name1.length }}
</div>
</template>
</draggable>
<draggable
v-model="models2"
:animation="150"
group="list"
item-key="name2"
class="item-list"
>
<template #item="{ element }">
<div class="item" :style="{ borderColor: element.color }">
{{ element.name2 }}
{{ element.name2.length }}
</div>
</template>
</draggable>
</div>
</template>
```
これで異なるリストの要素をD&Dすると以下のようなエラーになる。
```error
TypeError: Cannot read properties of undefined (reading 'length')
at http://localhost:5173/src/App.vue?t=1724488203243:93:86
at renderFnWithContext (http://localhost:5173/node_modules/.vite/deps/chunk-ILQ3RKOS.js?v=f47d7c91:2340:13)
```
## v-modelをやめる
[[v-model]]を使っている以上、ドロップ時にドラッグ元のmodelで更新されてしまうことは避けられない。ならば、[[v-model]]は使わずに`model-value`と`update:modelValue`を使えばいい。
### model-valueを使う
まずは[[v-model]]を削除して`model-value`を追加してみる。1つ目のリストの変更点だけを記載。
```diff
<draggable
- v-model="models1"
+ :model-value="models1"
:animation="150"
group="list"
item-key="name1"
class="item-list"
>
```
これで動かしてみると、ドラッグ&ドロップはできるが、ドロップ後には何事もなかったかのような動作になる。当然、ドロップ先のmodelにも影響はなく、[[computed()]]や[[watch (Vue)|watch]]も発動しない。
![[2024-08-24-17-38-25.webm]]
### update:model-valueを使う
次に`update:model-value`を追加する。
```diff
<draggable
:model-value="models1"
:animation="150"
group="list"
item-key="name1"
class="item-list"
+ @update:model-value="handleUpdateModels1"
>
```
`handleUpdateModels1`の実装は以下。
```ts
const handleUpdateModels1 = (...args: any[]) =>
args.forEach((x) => console.log(x));
```
これで動作確認してみる。
![[2024-08-24-17-44-40.webm]]
見た目は特に変化ないが、[[Chrome devtools]]に出力されたログには大きな変化がある。
```console
(7) [Proxy(Object), Proxy(Object), Proxy(Object), Proxy(Object), Proxy(Object), Proxy(Object), Proxy(Object)]
0: Proxy(Object) {name1: 'user1', color: 'red'}
1: Proxy(Object) {name1: 'user2', color: 'blue'}
2: Proxy(Object) {name2: 'user101', color: 'red'}
3: Proxy(Object) {name1: 'user3', color: 'green'}
4: Proxy(Object) {name1: 'user4', color: 'purple'}
5: Proxy(Object) {name1: 'user5', color: 'yellow'}
6: Proxy(Object) {name1: 'user6', color: 'black'}
```
ドロップした要素が指定箇所にしっかり挿入されていることが分かる。あとはこれを変換ロジックを通して型変換してやればよい。
### 変換処理で要素を変換する
2つの型は以下のような定義だったので
```ts
type Data1 = {
name1: string;
color: string;
};
type Data2 = {
name2: string;
color: string;
};
```
`handleUpdateModels1`をこのように実装する。[[The in operator narrowing]]で型は断定できる。
```ts
const handleUpdateModels1 = (items: (Data1 | Data2)[]) => {
models1.value = items.map((x) =>
"name1" in x ? x : { ...x, name1: x.name2 },
);
};
```
![[2024-08-24-17-54-30.webm]]
リスト2はまだ実装できていないのでちゃんと動かない。
## 【応用編】computed setterとの共存
今まで例はまだシンプルな例だったが、computed setterが共存する場合はどうなるか? 以下のケースを想定してみる。
- リスト1の最初と最後の要素はドラッグ&ドロップ対象外
- その他の間要素はドラッグ&ドロップ可能
要素のパターンが増えるので[[CSS]]の定義を増やす。
```css
<style scoped>
.list-container {
display: flex;
gap: 16px;
}
.list1 {
display: flex;
flex-direction: column;
gap: 8px;
}
.draggable-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.item {
padding: 8px;
border: solid 4px;
cursor: grab;
}
.item:active {
cursor: grabbing;
}
.fixed-item {
cursor: not-allowed;
background-color: lightgrey;
}
</style>
```
そしてtemplateの内容を変更。リスト2の内容もちゃんと動くようにしれっと修正。
```html
<template>
<div class="list-container">
<div class="list1">
<div class="item fixed-item" :style="{ borderColor: models1[0].color }">
{{ models1[0].name1 }}
{{ models1[0].name1.length }}
</div>
<draggable
:model-value="trimModels1"
:animation="150"
group="list"
item-key="name1"
class="draggable-list"
@update:model-value="handleUpdateModels1"
>
<template #item="{ element }">
<div class="item" :style="{ borderColor: element.color }">
{{ element.name1 }}
{{ element.name1.length }}
</div>
</template>
</draggable>
<div
class="item fixed-item"
:style="{ borderColor: models1[models1.length - 1].color }"
>
{{ models1[models1.length - 1].name1 }}
{{ models1[models1.length - 1].name1.length }}
</div>
</div>
<draggable
:model-value="models2"
:animation="150"
group="list"
item-key="name2"
class="draggable-list"
@update:model-value="handleUpdateModels2"
>
<template #item="{ element }">
<div class="item" :style="{ borderColor: element.color }">
{{ element.name2 }}
{{ element.name2.length }}
</div>
</template>
</draggable>
</div>
</template>
```
scriptのコードもすべて記載する。
```ts
import { computed, ref, watch } from "vue";
import draggable from "vuedraggable";
type Data1 = {
name1: string;
color: string;
};
type Data2 = {
name2: string;
color: string;
};
const models1 = ref<Data1[]>([
{ name1: "user1", color: "red" },
{ name1: "user2", color: "blue" },
{ name1: "user3", color: "green" },
{ name1: "user4", color: "purple" },
{ name1: "user5", color: "yellow" },
{ name1: "user6", color: "black" },
]);
const models2 = ref<Data2[]>([
{ name2: "user101", color: "red" },
{ name2: "user102", color: "blue" },
{ name2: "user103", color: "green" },
{ name2: "user104", color: "purple" },
{ name2: "user105", color: "yellow" },
{ name2: "user106", color: "black" },
]);
watch(
() => models1.value,
(v) => {
console.log(`[watch] models1.value: ${v.map((x) => x.name1)}`);
},
);
watch(
() => models2.value,
(v) => {
console.log(`[watch] models2.value: ${v.map((x) => x.name2)}`);
},
);
const handleUpdateModels1 = (items: (Data1 | Data2)[]) => {
trimModels1.value = items.map((x) =>
"name1" in x ? x : { name1: x.name2, color: x.color },
);
};
const handleUpdateModels2 = (items: (Data1 | Data2)[]) => {
models2.value = items.map((x) =>
"name2" in x ? x : { name2: x.name1, color: x.color },
);
};
const trimModels1 = computed({
get: () => models1.value.slice(1, -1),
set: (value) => {
models1.value = [
models1.value[0],
...value,
models1.value[models1.value.length - 1],
];
},
});
```
[[watch (Vue)|watch]]と`handleUpdateModels`は今までと変わりなく、リスト2に対応しただけなので説明は省略。重要なのは`trimModels1`が登場したこと。
```ts
const handleUpdateModels1 = (items: (Data1 | Data2)[]) => {
trimModels1.value = items.map((x) =>
"name1" in x ? x : { name1: x.name2, color: x.color },
);
};
const trimModels1 = computed({
get: () => models1.value.slice(1, -1),
set: (value) => {
models1.value = [
models1.value[0],
...value,
models1.value[models1.value.length - 1],
];
},
});
```
[[computed setter]]を使っているので少し複雑に見えるが以下のような関係。
![[Pasted image 20240824190312.png]]
`models`から先頭と末尾を除外したものが`trimModels`。`trimModels`は[[vue.draggable.next]]に渡され、D&Dで変更があると変更後のリスト値をイベントで通知し、モデルを正して`trimModels`を変更する。`trimModels`の変更を受け、[[computed setter]] `set` が起動し、`models` を再構成するという流れだ。その後に[[watch (Vue)|watch]]や[[computed()]]が発動する。
```ts
watch(
() => models1.value,
(v) => {
// ⑧ models1の値が変更されたのでwatchの処理が実行される
console.log(`[watch] models1.value: ${v.map((x) => x.name1)}`);
},
);
// ④ D&D後の要素リストがitems1として入ってくる. 異なるモデルの可能性あり
const handleUpdateModels1 = (items: (Data1 | Data2)[]) => {
// ⑤ モデルが正しく修正されたリストがtrimModels1に入る
trimModels1.value = items.map((x) =>
"name1" in x ? x : { name1: x.name2, color: x.color },
);
};
const trimModels1 = computed({
// ① models1の値からtrimModels1の値が決まる
get: () => models1.value.slice(1, -1),
// ⑥ trimModels1の値が変更されたので処理が走る
set: (value) => {
// ⑦ models1の値が再構築される
// ⚠️setによるmodels1の変更では①は実行されない(無限ループなるからね...)
models1.value = [
models1.value[0],
...value,
models1.value[models1.value.length - 1],
];
},
});
<template>
<draggable
// ② trimModels1の値が更新されたのでdraggableの再描画が走る
// ⑥ ⑤の変更を受けて再描画が走る
:model-value="trimModels1"
:animation="150"
group="list"
item-key="name1"
class="draggable-list"
// ③ 👥ユーザーがD&Dで要素を変更すると@update:model-valueが発動
@update:model-value="handleUpdateModels1"
>
<template #item="{ element }">
<div class="item" :style="{ borderColor: element.color }">
{{ element.name1 }}
{{ element.name1.length }}
</div>
</template>
</draggable>
</template>
```
以降はユーザートリガーの③、もしくはシステムトリガーの①(`models1`変更)がトリガーとなる。
## 最終成果物
以下のような動きになる。右側のデータ構造はオマケ。
![[2024-08-24-19-29-53.webm]]