LoginSignup
8
4

More than 3 years have passed since last update.

Vue.jsチュートリアル 〜Vue.js + TypeScriptでTrelloもどきを作ろう②〜

Last updated at Posted at 2019-12-18

この記事はVue.jsチュートリアル 〜Vue.js + TypeScriptでTrelloもどきを作ろう①〜の続編です。

リスト名とカードテキストの編集機能

次はリスト名とカードテキストの編集機能を作りましょう。
今回はcontenteditableというグローバル属性をtrueにすることによって、その要素のテキストを編集することにします。(DOMを変更し、それに基づいてデータを変更しています。まだVue.js自体contenteditaleをサポートしていないと思うので、input要素にした方がいいかもしれません。)

更新時の流れは以下の通りにします。

  1. リスト名がダブルクリックされると、その要素の文字を編集可能にしフォーカスを当てる。
  2. フォーカスが当たっている状態でEnterキーが押されると、フォーカスを外す。
  3. フォーカスが外れると、リスト名、またはカードテキストを更新する。

List.vueを編集していきましょう。
リスト名をdiv要素で囲い、その要素にcontenteditable属性とイベントリスナを付与します。
「更新時の流れ」より、ダブルクリック時にトリガされる@dblclick, エンターキー押下時にトリガされる@keypress.enter, フォーカスを外した時にトリガされる@blurを登録します。

List.vue
<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コンポーネントに新しくlistNamepropsとして渡して、listNameイベント修飾子syncを付けましょう。
イベント修飾子とはイベントハンドラに付与するオプションのようなものです。
ここでは「@PropSyncデコレータを使用する際にセットで必要になる」としか説明しないので、詳しく知りたい方はこちらを読んでください。

App.vue
<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.vuelistNameとして渡したので、@PropSyncの第一引数にlistNameを文字列として指定しましょう。
これでpropslistNameが登録されます。
第二引数には@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を直接更新するような書き方で更新することができるようになります。
「親コンポーネントに値を送って...」のようにせずに済み、とても簡潔に書くことができます。(ただしあまり乱用しすぎると危険な気はします...)

List.vue
<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>

それではカードテキストについても同様に書いていきましょう。

List.vue
<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>
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
Card.vue
<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>

これでリスト名, カードテキストを更新することができるようになりました。
この時点でのコミット

リストとカードの削除機能

次はリストとカードの削除機能について見ていきましょう。
削除するためのバツ印をそれぞれリスト名, カードテキストの右に表示し、それがクリックされると削除することにします。
まずバツ印をコンポーネントにしましょう。

Cross.vue
<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が発火されます。

List.vue
<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が発火されるので、リストを削除するロジックをイベントハンドラに登録しましょう。

App.vue
<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を渡しましょう。

List.vue
<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>
Card.vue
<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>
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
List.vue
<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>
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
App.vue
<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する手間が省けました。

App.vue
<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イベントのイベントオブジェクトから覆い被さられている側の要素を取得できます。

このイベントの発生時に、カーソルがその要素の右側にある場合は要素の右側にリストを追加し、逆にカーソルが要素の左側にある場合は要素の左側に追加しましょう。
そのためにまずはカーソルの位置がどちら側にあるかを取得します。

App.vue
<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型を使っていきましょう。

さて、これでリストを入れ替えることができましたので、カードも同様に入れ替えていきましょう。

App.vue
<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>
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
List.vue
<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イベントが発生しなくなります。(繋げる順番で挙動が変わる可能性がありますが、今回はどちらでも変わりません。)

List.vue
<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イベントリスナを選択します。

App.vue
<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に興味を持っていただければ何よりです。

8
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
4