この記事はVue.jsチュートリアル 〜Vue.js + TypeScriptでTrelloもどきを作ろう①〜の続編です。
リスト名とカードテキストの編集機能
次はリスト名とカードテキストの編集機能を作りましょう。
今回はcontenteditable
というグローバル属性をtrue
にすることによって、その要素のテキストを編集することにします。(DOMを変更し、それに基づいてデータを変更しています。まだVue.js自体contenteditale
をサポートしていないと思うので、input
要素にした方がいいかもしれません。)
更新時の流れは以下の通りにします。
- リスト名がダブルクリックされると、その要素の文字を編集可能にしフォーカスを当てる。
- フォーカスが当たっている状態でEnterキーが押されると、フォーカスを外す。
- フォーカスが外れると、リスト名、またはカードテキストを更新する。
List.vueを編集していきましょう。
リスト名をdiv
要素で囲い、その要素にcontenteditable
属性とイベントリスナを付与します。
「更新時の流れ」より、ダブルクリック時にトリガされる@dblclick
, エンターキー押下時にトリガされる@keypress.enter
, フォーカスを外した時にトリガされる @blur
を登録します。
<template>
<div class="list">
+ <div
+ :contenteditable="contenteditable"
+ @dblclick="onDoubleClick"
+ @keypress.enter="onKeyPressEnter"
+ @blur="onBlur"
+ >
{{ list.name }}
+ </div>
<Card
v-for="card in list.cards"
:key="card.id"
class="card"
:card="card"
/>
<input type="text" class="card-input" @change="addCard" />
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop, Emit, PropSync } from "vue-property-decorator";
import Card from "@/components/Card.vue";
import { IList } from "@/types";
export interface IAddCardEvent {
listId: number;
text: string;
}
@Component({
components: {
Card
}
})
export default class List extends Vue {
@Prop({ type: Object, required: true })
readonly list!: IList;
+ contenteditable = false;
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
+ onDoubleClick(event: MouseEvent & { currentTarget: HTMLDivElement }): void {
// 要素のテキストを編集可能にする
+ this.contenteditable = true;
// 要素にフォーカスを当てる
+ event.currentTarget.focus();
+ }
+ onKeyPressEnter(
+ event: KeyboardEvent & { currentTarget: HTMLDivElement }
+ ): void {
// 要素からフォーカスを外す
+ event.currentTarget.blur();
+ }
+ onBlur(event: FocusEvent & { currentTarget: HTMLDivElement }): void {
// 要素のテキストを編集不可にする
+ this.contenteditable = false;
+ }
}
</script>
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
リスト名を更新する処理を書いていきましょう。
今回はリスト名自体, カードテキスト自体を更新するので@PropSync
デコレータを使うことにします。
@PropSync
デコレータを使うことで、データを渡された子コンポーネント側でデータを更新する処理を書くことができ、とても簡潔に書くことができます。
List
コンポーネントに新しくlistName
をprops
として渡して、listName
にイベント修飾子のsync
を付けましょう。
イベント修飾子とはイベントハンドラに付与するオプションのようなものです。
ここでは「@PropSync
デコレータを使用する際にセットで必要になる」としか説明しないので、詳しく知りたい方はこちらを読んでください。
<template>
<div id="app">
<List
v-for="list in lists"
:key="list.id"
class="list"
:list="list"
+ :listName.sync="list.name"
@add-card="addCard"
/>
<input type="text" class="list-input" @change="addList" />
</div>
</template>
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
App.vue
でlistName
として渡したので、@PropSync
の第一引数にlistName
を文字列として指定しましょう。
これでprops
にlistName
が登録されます。
第二引数には@Prop
デコレータと同じオプションを指定します。
デコレータを付与するプロパティ名は何でもいいですが、ここでは公式ドキュメントのようにsyncedListName
としましょう。
型はstring
でもいいですが、ブラケット記法でそのプロパティの型を取得できるのでIList["name"]
としましょう。
(@PropSync("listName", { type: String, required: true }) syncedListName!: IList["name"];
)
これは内部的にはcomputed(算出プロパティ)のgetterとsetterに)以下のように登録されます。
<script lang="ts">
export default class List extends Vue {
get syncedListName() {
return this.listName;
}
set syncedListName(value) {
this.$emit('update:listName', value);
}
}
</script>
これで、this.syncedListName = value
と書くことで、props
を直接更新するような書き方で更新することができるようになります。
「親コンポーネントに値を送って...」のようにせずに済み、とても簡潔に書くことができます。(ただしあまり乱用しすぎると危険な気はします...)
<script lang="ts">
+ import { Component, Vue, Prop, Emit, PropSync } from "vue-property-decorator";
import Card from "@/components/Card.vue";
import { IList } from "@/types";
export interface IAddCardEvent {
listId: number;
text: string;
}
@Component({
components: {
Card
}
})
export default class List extends Vue {
@Prop({ type: Object, required: true })
readonly list!: IList;
+ @PropSync("listName", { type: String, required: true })
+ syncedListName!: IList["name"];
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
onBlur(event: FocusEvent & { currentTarget: HTMLDivElement }): void {
+ this.syncedListName = event.currentTarget.innerText;
this.contenteditable = false;
}
}
</script>
それではカードテキストについても同様に書いていきましょう。
<template>
<div class="list">
<div
@dblclick="onDoubleClick"
@keypress.enter="onKeyPressEnter"
@blur="onBlur"
>
{{ list.name }}
</div>
<Card
v-for="card in list.cards"
:key="card.id"
class="card"
:card="card"
+ :cardText.sync="card.text"
/>
<input type="text" class="card-input" @change="addCard" />
</div>
</template>
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
<template>
<div class="card">
+ <div
+ :contenteditable="contenteditable"
+ @dblclick="onDoubleClick"
+ @keypress.enter="onKeyPressEnter"
+ @blur="onBlur"
+ >
{{ card.text }}
+ </div>
</div>
</template>
<script lang="ts">
+ import { Component, Vue, Prop, PropSync } from "vue-property-decorator";
import { ICard } from "@/types";
@Component
export default class Card extends Vue {
@Prop({ type: Object, required: true })
readonly card!: ICard;
+ @PropSync("cardText", { type: String, required: true })
+ syncedCardText!: ICard["text"];
+ contenteditable = false;
+ onDoubleClick(event: MouseEvent & { currentTarget: HTMLDivElement }): void {
+ this.contenteditable = true;
+ event.currentTarget.focus();
+ }
+ onKeyPressEnter(
event: KeyboardEvent & { currentTarget: HTMLDivElement }
): void {
+ event.currentTarget.blur();
+ }
+ onBlur(event: FocusEvent & { currentTarget: HTMLDivElement }): void {
+ this.syncedCardText = event.currentTarget.innerText;
+ this.contenteditable = false;
+ }
}
</script>
これでリスト名, カードテキストを更新することができるようになりました。
この時点でのコミット
リストとカードの削除機能
次はリストとカードの削除機能について見ていきましょう。
削除するためのバツ印をそれぞれリスト名, カードテキストの右に表示し、それがクリックされると削除することにします。
まずバツ印をコンポーネントにしましょう。
<template>
<span class="cross" @click="click" />
</template>
<script lang="ts">
import { Component, Vue, Emit } from "vue-property-decorator";
@Component
export default class Cross extends Vue {
@Emit()
click(): void {}
}
</script>
<style lang="scss" scoped>
.cross {
display: inline-block;
width: 16px;
height: 16px;
position: absolute;
&:before,
&:after {
width: 100%;
border-top: 1px solid #000000;
position: absolute;
top: 50%;
content: "";
}
&:before {
transform: rotate(-45deg);
}
&:after {
transform: rotate(45deg);
}
}
</style>
このバツ印をクリックするとカスタムイベントclick
が発火されます。
リストを削除するために必要なデータは、削除するリストのidです。
なので、カスタムイベントclick
をカスタム要素Cross
に登録し、イベントハンドラとしてremoveList
を登録しましょう。
removeList
に@Emit()
を付与することでカスタムイベントremove-list
が発火されます。
<template>
<div class="list">
<div
class="list-name"
:contenteditable="contenteditable"
@dblclick="onDoubleClick"
@keypress.enter="onKeyPressEnter"
@blur="onBlur"
>
+ <Cross @click="removeList" />
{{ list.name }}
</div>
<Card
v-for="card in list.cards"
:key="card.id"
class="card"
:card="card"
:cardText.sync="card.text"
/>
<input type="text" class="card-input" @change="addCard" />
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop, Emit, PropSync } from "vue-property-decorator";
import Card from "@/components/Card.vue";
+ import Cross from "@/components/Cross.vue";
import { IList } from "@/types";
import { IRemoveCardEvent } from "@/components/Card.vue";
export interface IAddCardEvent {
listId: number;
text: string;
}
@Component({
components: {
Card,
+ Cross
}
})
export default class List extends Vue {
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
+ @Emit()
+ removeList(): number {
+ return this.list.id;
+ }
}
</script>
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
List.vueでカスタムイベントremove-list
が発火されるので、リストを削除するロジックをイベントハンドラに登録しましょう。
<template>
<div id="app">
<List
v-for="list in lists"
:key="list.id"
class="list"
:list="list"
:listName.sync="list.name"
@add-card="addCard"
+ @remove-list="removeList"
/>
<input type="text" class="list-input" @change="addList" />
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import List from "@/components/List.vue";
import { IList } from "@/types";
import { initialLists } from "@/initialData";
import { IAddCardEvent } from "@/components/List.vue";
import { IRemoveCardEvent } from "@/components/Card.vue";
@Component({
components: {
List
}
})
export default class App extends Vue {
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
+ removeList(listId: number): void {
+ const listIndex = this.lists.findIndex(list => list.id === listId);
// findIndexで見つからない場合は-1を返すのでその場合は早期リターン
+ if (listIndex === -1) return;
+ this.lists.splice(listIndex, 1);
+ }
}
</script>
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
これでリストを削除できるようになりました。
カードも同様に削除できるようにします。
カードを削除するためには、どのリストのどのカードなのかを特定する必要があるので、そのカードのidとそのカードのリストのidが必要なデータです。
なので先にList.vueでカードにリストのidを渡しましょう。
<template>
<div class="list">
<div
class="list-name"
@dblclick="onDoubleClick"
@keypress.enter="onKeyPressEnter"
@blur="onBlur"
>
<Cross @click="removeList" />
{{ list.name }}
</div>
<Card
v-for="card in list.cards"
:key="card.id"
class="card"
+ :listId="list.id"
:card="card"
:cardText.sync="card.text"
/>
<input type="text" class="card-input" @change="addCard" />
</div>
</template>
<template>
<div class="card">
<div
class="card-name"
@dblclick="onDoubleClick"
@keypress.enter="onKeyPressEnter"
@blur="onBlur"
>
+ <Cross @click="removeCard" />
{{ card.text }}
</div>
</div>
</template>
<script lang="ts">
+ import { Component, Vue, Prop, Emit, PropSync } from "vue-property-decorator";
+ import Cross from "@/components/Cross.vue";
+ import { ICard, IList } from "@/types";
+ export interface IRemoveCardEvent {
+ listId: number;
+ cardId: number;
+ }
@Component({
+ components: {
+ Cross
+ }
})
export default class Card extends Vue {
+ @Prop({ type: Number, required: true })
+ listId!: IList["id"];
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
+ @Emit()
+ removeCard(): IRemoveCardEvent {
+ return {
+ listId: this.listId,
+ cardId: this.card.id
+ };
+ }
}
</script>
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
<template>
<div class="list">
<div
class="list-name"
@dblclick="onDoubleClick"
@keypress.enter="onKeyPressEnter"
@blur="onBlur"
>
<Cross @click="removeList" />
{{ list.name }}
</div>
<Card
v-for="card in list.cards"
:key="card.id"
class="card"
:listId="list.id"
:card="card"
:cardText.sync="card.text"
+ @remove-card="removeCard"
/>
<input type="text" class="card-input" @change="addCard" />
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop, Emit, PropSync } from "vue-property-decorator";
import Card from "@/components/Card.vue";
import Cross from "@/components/Cross.vue";
import { IList } from "@/types";
+ import { IRemoveCardEvent } from "@/components/Card.vue";
export interface IAddCardEvent {
listId: number;
text: string;
}
@Component({
components: {
Card,
Cross
}
})
export default class List extends Vue {
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
+ @Emit()
+ removeCard(event: IRemoveCardEvent): IRemoveCardEvent {
+ return event;
+ }
}
</script>
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
<template>
<div id="app">
<List
v-for="list in lists"
:key="list.id"
class="list"
:list="list"
:listName.sync="list.name"
@add-card="addCard"
@remove-list="removeList"
+ @remove-card="removeCard"
/>
<input type="text" class="list-input" @change="addList" />
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import List from "@/components/List.vue";
import { IList } from "@/types";
import { initialLists } from "@/initialData";
import { IAddCardEvent } from "@/components/List.vue";
+ import { IRemoveCardEvent } from "@/components/Card.vue";
@Component({
components: {
List
}
})
export default class App extends Vue {
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
+ removeCard({ listId, cardId }: IRemoveCardEvent): void {
+ const list = this.lists.find(list => list.id === listId);
+ if (list === undefined) return;
+ const cardIndex = list.cards.findIndex(card => card.id === cardId);
+ if (cardIndex === -1) return;
+ list.cards.splice(cardIndex, 1);
+ }
}
</script>
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
これでカードを削除することができるようになりました。
この時点でのコミット
リスト、カードの移動機能
さていよいよ最後の移動機能です。これでTrelloっぽくなると思います。
いつものようにリストからです。
まず入れ替えるロジックについて考えてみましょう。
いくつか方法はあると思います。
ここではリストをドラッグして、カーソルの位置が入れ替え先のリストの横幅に対する縦の中央線の上を超えたら、ドラッグしているリストと入れ替え先のリストを入れ替えるというロジックにしましょう。
まず必要なデータはドラッグ中のリストです。
このデータを一時的に保存するために、ドラッグし始めたらdraggedList
というdata
にドラッグ中のリストを代入し、ドラッグが終わったらnull
を代入してリセットしましょう。
要素をドラッグするためには、その要素にdraggable
属性を追加します。
ドラッグ関連のイベントを下にまとめます。
参考
イベント名 | 発生タイミング |
---|---|
dragstart | ドラッグ開始時 |
drag | ドラッグが継続している間 |
dragenter | ドラッグ要素がドロップ要素に入った時 |
dragleave | ドラッグ要素がドロップ要素から出た時 |
dragover | ドラッグ要素がドロップ要素に重なっている間 |
drop | ドロップ時 |
dragend | ドラッグ終了時 |
この表からドラッグ開始時に発生するイベントはdragstart
、ドラッグ終了時に発生するイベントはdragend
ということがわかります。
この2つのイベントリスナを要素に登録します。
ここでnative
というイベント修飾子を使いましょう。
カスタム要素にイベントリスナを登録する時にこの修飾子を使うと、カスタム要素のコンポーネントのルート要素にイベントリスナを付与することができます。
具体的には@dragstart.native="イベントハンドラ"
をList
に登録することで、List
コンポーネントの<div class="list"></div>
にこのイベントリスナを付与することができます。
これでListコンポーネント内でイベントリスナを付与したり、emit
する手間が省けました。
<template>
<div id="app">
<List
v-for="list in lists"
:key="list.id"
class="list"
:list="list"
:listName.sync="list.name"
@add-card="addCard"
@remove-list="removeList"
@remove-card="removeCard"
+ draggable
+ @dragstart.native="setDraggedList(list, $event)"
+ @dragend.native="resetDraggedList"
/>
<input type="text" class="list-input" @change="addList" />
</div>
</template>
<script lang="ts">
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
@Component({
components: {
List
}
})
export default class App extends Vue {
lists: IList[] = initialLists;
listCreatedCount = 2;
cardCreatedCount = 4;
// ドラッグ中のリスト
+ draggedList: IList | null = null;
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
+ setDraggedList(list: IList, event: DragEvent): void {
// Firefoxでドラッグする際に必要(詳しい説明は省略)
+ if (event.dataTransfer == null) return;
+ event.dataTransfer.setData("text/plain", "");
+
+ this.draggedList = list;
+ }
+ resetDraggedList(): void {
+ this.draggedList = null;
+ }
}
</script>
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
これでドラッグの開始時に、ドラッグしているリストのデータがdraggedList
に代入されます。
そしてドラッグの終了時に、null
が代入されdraggedList
がリセットされます。
ここで$event
という特別な変数が出てきました。
メソッド側でイベントオブジェクトを使う場合は、イベントハンドラに何も渡されなければメソッドの第一引数でイベントオブジェクトを受け取ることができます。
しかしlist
を渡しているので、明示的に$event
を引数に渡すことで、メソッドにイベントオブジェクトを渡すことができます。
次にリストを移動させる処理を追加します。
今回はdragover
イベントリスナを選択します。
dragover
はドラッグしている要素が同じ要素に重なっている時に、覆い被さられている側の要素で連続で発生するイベントです。
このdragover
イベントのイベントオブジェクトから覆い被さられている側の要素を取得できます。
このイベントの発生時に、カーソルがその要素の右側にある場合は要素の右側にリストを追加し、逆にカーソルが要素の左側にある場合は要素の左側に追加しましょう。
そのためにまずはカーソルの位置がどちら側にあるかを取得します。
<template>
<div id="app">
<List
v-for="list in lists"
:key="list.id"
class="list"
:list="list"
:listName.sync="list.name"
@add-card="addCard"
@remove-list="removeList"
@remove-card="removeCard"
draggable
@dragstart.native="setDraggedList(list, $event)"
+ @dragover.native="moveList(list.id, $event)"
@dragend.native="resetDraggedList"
/>
<input type="text" class="list-input" @change="addList" />
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import List from "@/components/List.vue";
import { IList, ICard } from "@/types";
import { initialLists } from "@/initialData";
import { IAddCardEvent, IMoveCardEvent } from "@/components/List.vue";
import { IRemoveCardEvent } from "@/components/Card.vue";
+ enum CursorPosition {
+ Left,
+ Right,
+ Center,
+ }
@Component({
components: {
List
}
})
export default class App extends Vue {
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
+ moveList(
+ listId: number,
+ event: DragEvent & { currentTarget: HTMLDivElement }
+ ): void {
+ if (
+ this.draggedList === null || // ドラッグしている要素が無い場合は早期リターン
+ this.draggedList.id === listId // ドラッグしている要素と重ねられている要素が同じ場合は早期リターン
+ )
+ return;
// 重ねられている要素の左端の座標(px)
+ const left: number = event.currentTarget.getBoundingClientRect().left - 1;
// 重ねられている要素の右端の座標(px)
+ const right: number = event.currentTarget.getBoundingClientRect().right - 1;
// 重ねられている要素(の水平方向)の中央の座標(px)
+ const centerX: number = left + (right - left) / 2;
// カーソルが、重ねられている要素のどこにあるか
+ const cursorPosition: CursorPosition = (() => {
// カーソルが左端から中央の場合は左側
+ if (left <= event.clientX && event.clientX < centerX) {
+ return CursorPosition.Left;
// カーソルが中央から右端の場合は右側
+ } else if (centerX < event.clientX && event.clientX <= right) {
+ return CursorPosition.Right;
// どちらでもない場合は中央
+ } else CursorPosition.Center;
+ })();
// 中央の時は早期リターン
+ if (cursorPosition === CursorPosition.Center) return;
// まずドラッグしているリストを削除する
+ const draggedListIndex = this.lists.findIndex(
+ list => list.id === this.draggedList!.id
+ );
+ if (draggedListIndex === -1) return;
+ this.lists.splice(draggedListIndex, 1);
+ const listIndex = this.lists.findIndex(list => list.id === list.id);
+ if (listIndex === -1) return;
// カーソルが重ねられている要素の左側にある場合は左、右側にある場合は右に要素を追加する
+ const toListIndex =
+ cursorPosition === CursorPosition.left ? listIndex : listIndex + 1;
+ this.lists.splice(toListIndex, 0, this.draggedList);
+ }
}
</script>
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
enum型は定数のまとまりのような型です。
enum型は型としても値としても使うことができ、値として使う場合はオブジェクト、型として使う場合はオブジェクトのプロパティのunion型として使うことができます。
定数を使う場合は積極的にenum型を使っていきましょう。
さて、これでリストを入れ替えることができましたので、カードも同様に入れ替えていきましょう。
<template>
<div id="app">
<List
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
+ :draggedCardData.sync="draggedCardData"
+ @move-card="moveCard"
/>
<input type="text" class="list-input" @change="addList" />
</div>
</template>
<script lang="ts">
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
enum CursorPosition {
Left,
Right,
Center,
+ Top,
+ Bottom,
}
+ export interface ICardData {
+ listId: number;
+ card: ICard;
+ }
@Component({
components: {
List
}
})
export default class App extends Vue {
lists: IList[] = initialLists;
listCreatedCount = 2;
cardCreatedCount = 4;
draggedList: IList | null = null;
+ draggedCardData: ICardData | null = null;
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
+ moveCard({ listId, cardId, event }: IMoveCardEvent): void {
+ if (
+ this.draggedCardData === null || // ドラッグしている要素が無い場合は早期リターン
+ this.draggedCardData.card.id === cardId // ドラッグしている要素と重ねられている要素が同じ場合は早期リターン
+ )
+ return;
// 重ねられている要素の上端の座標(px)
+ const top: number = event.currentTarget.getBoundingClientRect().top - 1;
// 重ねられている要素の下端の座標(px)
+ const bottom: number =
+ event.currentTarget.getBoundingClientRect().bottom - 1;
// 重ねられている要素(の垂直方向)の中央の座標(px)
+ const centerY: number = top + (bottom - top) / 2;
// カーソルが、重ねられている要素のどこにあるか
+ const cursorPosition: CursorPosition = (() => {
// カーソルが上端から中央の場合は上側
+ if (top <= event.clientY && event.clientY < centerY) {
+ return CursorPosition.Top;
// カーソルが下端から中央の場合は下側
+ } else if (centerY < event.clientY && event.clientY <= bottom) {
+ return CursorPosition.Bottom;
// どちらでもない場合は中央
+ } else return CursorPosition.Center;
+ })();
// 中央の時は早期リターン
+ if (cursorPosition === CursorPosition.Center) return;
// まずドラッグしているカードを削除する
+ const draggedCardList = this.lists.find(
+ list => list.id === this.draggedCardData!.listId
+ );
+ if (draggedCardList === undefined) return;
+ const draggedCardIndex = draggedCardList.cards.findIndex(
+ card => card.id === this.draggedCardData!.card.id
+ );
+ if (draggedCardIndex === -1) return;
+ draggedCardList.cards.splice(draggedCardIndex, 1);
+ const list = this.lists.find(list => list.id === listId);
+ if (list === undefined) return;
+ const cardIndex = list.cards.findIndex(card => card.id === cardId);
+ if (cardIndex === -1) return;
// カーソルが、重ねられている要素の上にある場合は上、下側にある場合は下に要素を追加する
+ const toCardIndex =
+ cursorPosition === CursorPosition.Top ? cardIndex : cardIndex + 1;
+ list.cards.splice(toCardIndex, 0, this.draggedCardData.card);
// 属するリストが変わる可能性があるので、移動後のリストのidを代入
+ this.draggedCardData.listId = listId;
+ }
}
</script>
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
<template>
<div class="list">
<div
class="list-name"
:contenteditable="contenteditable"
@dblclick="onDoubleClick"
@keypress.enter="onKeyPressEnter"
@blur="onBlur"
>
<Cross @click="removeList" />
{{ list.name }}
</div>
<Card
v-for="card in list.cards"
:key="card.id"
class="card"
:listId="list.id"
:card="card"
:cardText.sync="card.text"
@remove-card="removeCard"
+ draggable
+ @dragstart.native="onDragStart(card, $event)"
+ @dragover.native="moveCard(card.id, $event)"
+ @dragend.native="onDragEnd"
/>
<input type="text" class="card-input" @change="addCard" />
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop, Emit, PropSync } from "vue-property-decorator";
import Card from "@/components/Card.vue";
import Cross from "@/components/Cross.vue";
import { IList, ICard } from "@/types";
import { IRemoveCardEvent } from "@/components/Card.vue";
+ import { ICardData } from "@/App.vue";
export interface IAddCardEvent {
listId: number;
text: string;
}
export interface IMoveCardEvent {
listId: number;
cardId: number;
event: DragEvent & { currentTarget: HTMLDivElement };
}
@Component({
components: {
Card,
Cross
}
})
export default class List extends Vue {
@Prop({ type: Object, required: true })
readonly list!: IList;
@PropSync("listName", { type: String, required: true })
syncedListName!: IList["name"];
+ @PropSync("draggedCardData", { required: true })
+ syncedDraggedCardData!: ICardData | null;
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
+ onDragStart(card: ICard, event: DragEvent): void {
+ if (event.dataTransfer == null) return;
+ event.dataTransfer.setData("text/plain", "");
+ this.syncedDraggedCardData = {
+ listId: this.list.id,
+ card
+ };
+ }
+ @Emit()
+ moveCard(
+ cardId: number,
+ event: DragEvent & { currentTarget: HTMLDivElement }
+ ): IMoveCardEvent {
+ return {
+ listId: this.list.id,
+ cardId,
+ event
+ };
+ }
+ onDragEnd(): void {
+ this.syncedDraggedCardData = null;
+ }
}
</script>
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
これでカードの移動機能を作成できました。
しかしここで1つ問題があります。
カードをドラッグして、「リストを入れ替えるときと同じように」リストの中央線を超えるとリストも入れ替わってしまいます。
これはイベントの伝搬による挙動です。
イベントの伝搬とは、イベントの発生元の要素からwindow
オブジェクトまでイベントが伝わるという挙動です。
これによりカードでドラッグすると、イベントの発生元の要素から親要素に向かって要素ひとつひとつでdragstartイベントが発生し続けてしまいます。
そしてList
コンポーネントのルート要素に付与したイベントリスナがdragstartイベントをキャプチャしてしまい、リストのonDragStart
イベントハンドラが発火してしまいます。
これが原因でリストも移動してしまいます。
これを防ぐにはstop
イベント修飾子を使います。(内部的にはevent.stopPropagation()
を実行しています。)
イベント修飾子は.
で繋げて複数指定することができるので、これをCard
に付与した@dragstart
イベントリスナのnative
の後に繋げましょう。
これにより、この要素以降ではdragstartイベントが発生しなくなります。(繋げる順番で挙動が変わる可能性がありますが、今回はどちらでも変わりません。)
<template>
<div class="list">
<div
class="list-name"
:contenteditable="contenteditable"
@dblclick="onDoubleClick"
@keypress.enter="onKeyPressEnter"
@blur="onBlur"
>
<Cross @click="removeList" />
{{ list.name }}
</div>
<Card
v-for="card in list.cards"
:key="card.id"
class="card"
:listId="list.id"
:card="card"
:cardText.sync="card.text"
@remove-card="removeCard"
draggable
+ @dragstart.native.stop="onDragStart(card, $event)"
@dragover.native="moveCard(card.id, $event)"
@dragend.native="onDragEnd"
/>
<input type="text" class="card-input" @change="addCard" />
</div>
</template>
リストが移動しなくなりましたね。
最後にリスト内のカードが空の時でも移動できるようにしましょう。
今回はカーソルがリストの要素内に入った瞬間にイベントを発火させたいので、@dragenter
イベントリスナを選択します。
<template>
<div id="app">
<List
v-for="list in lists"
:key="list.id"
class="list"
:list="list"
:listName.sync="list.name"
@add-card="addCard"
@remove-list="removeList"
@remove-card="removeCard"
draggable
@dragstart.native="setDraggedList(list, $event)"
@dragover.native="moveList(list.id, $event)"
@dragend.native="resetDraggedList"
:draggedCardData.sync="draggedCardData"
@move-card="moveCard"
+ @dragenter.native="moveCardForEmpty(list.id)"
/>
<input type="text" class="list-input" @change="addList" />
</div>
</template>
<script lang="ts">
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
@Component({
components: {
List
}
})
export default class App extends Vue {
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
+ moveCardForEmpty(listId: number): void {
+ if (
+ this.draggedCardData === null ||
+ this.draggedCardData.listId === listId
+ )
+ return;
+ const list = this.lists.find(list => list.id === listId);
+ if (
+ list === undefined ||
+ list.cards.length !== 0 // リスト内にカードが1つでもある場合は早期リターン
+ )
+ return;
+ const draggedCardList = this.lists.find(
+ list => list.id === this.draggedCardData!.listId
+ );
+ if (draggedCardList === undefined) return;
+ const draggedCardIndex = draggedCardList.cards.findIndex(
+ card => card.id === this.draggedCardData!.card.id
+ );
+ if (draggedCardIndex === -1) return;
+ draggedCardList.cards.splice(draggedCardIndex, 1);
+ list.cards.push(this.draggedCardData.card);
+ this.draggedCardData.listId = listId;
+ }
}
</script>
これでリスト内のカードが空の場合でも移動させることができるようになりました。
この時点でのコミット
いかがでしたか?やっぱりHTML要素を動かすことができると楽しいですよね。
Vue.js, TypeScriptにはまだまだたくさんの素晴らしい機能があります。
このチュートリアルがきっかけでVue.jsやTypeScriptに興味を持っていただければ何よりです。