SVG要素を包含したVueのコンポーネントでマウスイベントを発火させる方法

この記事は [「Vue Advent Calendar 2019 #1」](https://qiita.com/advent-calendar/2019/vue) 15日目の記事です。

#概要
「SVG要素を包含したVueのコンポーネント」で、マウスイベントを発火させる方法をご紹介します。

とても簡単な内容に思えるのですが、mousedown 等のイベントを上位のコンポーネントで単純に v-on するだけではうまく動きませんでした。

#要約（五七五）
イベントは　$emit させると　いい感じ

#背景
Vue.jsとSVGを使って、**超簡易ビジュアルプログラミング環境**を開発中です。[^vlockflow]
「ブロック」を「リンク」で繋ぐことで処理の流れを構築する、ビジュアルプログラミングとしてはよくある感じのやつです。

[^vlockflow]: あくまでおもちゃレベルのもの。昔から作ってみたかったのですが、ようやく自分の技術が追いついてきたので作り始めました。

![VlockFlow_DragAndDrop.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/120785/3bb90178-00a8-0fac-f66e-d981e681d1ac.gif)


上図のようなものを描くには、「ブロック」を移動させたり「リンク」を繋いだりする操作が必要になります。つまり、ドラッグ＆ドロップが必要です。

このとき、ドラッグ＆ドロップしたいのは「 Vue のコンポーネント」です。例えば上記画像で動かしている、緑色で calc という文字列が書いてある「ブロック」は「 SVG の rect と text と ciecle を1つのブロックとしてコンポーネント化」してあります。

1つの rect だけをドラッグするようなサンプルはすぐ見つかります。しかしその方法を適用するだけでは、上記例なら緑色の角丸四角形だけが移動してしまい、calc という文字列や上下に付いている小さな丸は取り残されてしまいます。

それを解決するには、一工夫必要でした。

#コンポーネント分割
今回のプロジェクトは、静的サイトとして generate するために Nuxt.js を採用しています。従って、コンポーネントは`components`フォルダの配下に入れることになります。さらに、SVG で表現する部分については [Atomic Design](http://atomicdesign.bradfrost.com/) の考え方を導入してコンポーネントを分割しました。[^atomicdesign]

[^atomicdesign]: Atomic Design は元々UIデザインの設計手法のひとつですが、 Vue.js の「単一ファイルコンポーネント」の設計思想とも親和性が高いと感じます。

全体は割愛しますが、ブロック関連の部分については、以下のように構成されています。

- components/organisms
 - VFCanvas.vue　　　キャンバス。ブロック、リンクを配置する場所。
- components/molecules
 - VFBlock.vue　　　 ブロック。VFCrust, VFLabel, VFConnector を組み合わせて構築
- components/atoms
 - VFCrust.vue　　　 ベースとなる緑色の角丸四角形[^crust]
 - VFLabel.vue　　　 ブロック名等を表示するためのラベル
 - VFConnector.vue　ブロックの上下に付く、円形のコネクタ

[^crust]: ピザやタルトのベース部分を crust と呼ぶので、この命名にしてみました。


#マウスイベントを発火させる
ここからが本題です。

あるコンポーネントでマウスイベントを検知するなら、以下のようなコードを発想すると思います。

```vue:ParentComponent.vue
<template>
  <div>
    <svg>
      <SomeChildComponent
        v-for="item in items"
        :key="item.id"
        @mousedown="execMouseDown"
        @mouseup="execMouseUp"
        @mousemove="execMouseMove"
      />
    </svg>
  </div>
</template>

```

しかし、これだけではイベントが発火しません。Vue のコンポーネントは、そのままではマウスイベントを発火してくれないからです。

もう少し詳しくみていきます。

##動かない例
※`<script>`内のコードはここでは割愛。

```vue:VFCrust.vue
<!-- 最下層のコンポーネント -->
<!-- ここにはイベントリスナは記述していない -->
<template>
  <g>
    <rect
      :x="x"
      :y="y"
      :rx="rx"
      :ry="ry"
      :width="width"
      :height="height"
      :fill="fill"
    />
  </g>
</template>
```


```vue:VFBlock.vue
<!-- 中間のコンポーネント -->
<!-- ここにもイベントリスナは記述していない -->
<template>
  <g
    :x="x"
    :y="y"
  >
    <vf-crust
      :x="block_x"
      :y="block_y"
    />
    <vf-label
      :x="block_x"
      :y="block_y"
    />
    <vf-connector
      :x="block_x"
      :y="block_y"
    />
  </g>
</template>
```


```vue:VFCanvas.vue
<!-- 最上位のコンポーネント -->
<!-- mousedown, mouseup, mousemove を検出したい -->
<!-- マウスイベントを listen する v-onディレクティブはここにだけ記述 -->
<template>
  <div>
    <svg>
      <vf-block
        v-for="block in blocks"
        :key="block.id"
        :x="block.x"
        :y="block.y"
        @mousedown="execMouseDown"
        @mouseup="execMouseUp"
        @mousemove="execMouseMove"
      />
    </svg>
  </div>
</template>
```


このとき、デベロッパーツールで Event Listeners の状態を確認すると、どこにも mousedown, mouseup, mousemove のイベントが登録されていないことがわかります。

ここの g 要素が VFCanvas.vue での vf-block に相当するので、各イベントが登録されていてほしいのですが、、、
<img width="238" alt="スクリーンショット 2019-12-15 2.37.49.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/120785/8bac7bbe-3691-0c77-6446-446e973fea8b.png">

ありません。
<img width="155" alt="スクリーンショット 2019-12-15 2.35.07.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/120785/1fbd3ea6-43e1-249a-5159-3faf5a33b9eb.png">

配下の g 要素や rect 要素も同様に、mousedown, mouseup, mousemove のイベントは登録されていませんでした。

※補足：この時点では気づいていなかったのですが、動作するようになった最終形でも vf-block にはイベントリスナは登録されませんでした。DOM要素に追加されるわけではなく、Vueが内部でうまく処理してくれているだけのようです。（要研究）


##一歩前へ（まだ動かない）

では、実際の DOM 要素である、SVG の rect 要素にイベントリスナを追加してみましょう。

```vue:VFCrust.vue
<!-- ここでマウスイベントを拾う -->
<template>
  <g>
    <rect
      :x="x"
      :y="y"
      :rx="rx"
      :ry="ry"
      :width="width"
      :height="height"
      :fill="fill"
      @mousedown="execMouseDown"
      @mousemove="execMouseMove"
      @mouseup="execMouseUp"
    />
  </g>
</template>

<script>
export default {
//（中略）
  methods: {
    execMouseDown: function(event){
      console.log("mousedown")
    },
    execMouseMove: function(event){
      console.log("mousemove")
    },
    execMouseUp: function(event){
      console.log("mouseup")
    },
  },
//（後略）
}
</script>
```

このコードを組み込んだ時、rect要素には mousedown, mousemove, mouseup の各イベントリスナが登録されました。

<img width="158" alt="スクリーンショット 2019-12-15 3.18.16.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/120785/4d771b33-f457-e64a-e3f9-9335885d02a2.png">


しかし、実際に動作させると、上位側ではイベントを拾うことができませんでした。ドラッグ＆ドロップを実現するには、上位コンポーネントでイベントをハンドリングしたいので、これでは不十分です。


##動く例

では、どのようにすれば動くのでしょうか。

SVG の各要素（ rect, circle 等）はマウス操作を検知してイベントを発火してくれますので、これらの**SVG の各要素で発火したイベントを、上位のコンポーネントへ伝達**していけば、うまく動きそうです。

伝達方法は、Vue公式の[スタイルガイド](https://jp.vuejs.org/v2/style-guide/index.html#%E6%9A%97%E9%BB%99%E7%9A%84%E3%81%AA%E8%A6%AA%E5%AD%90%E9%96%93%E3%81%AE%E3%82%84%E3%82%8A%E3%81%A8%E3%82%8A-%E4%BD%BF%E7%94%A8%E6%B3%A8%E6%84%8F)にある`props down, events up`の原則に従い、`$emit`で`events up`していく形にします。

このとき`this.$emit(event.type, event)`という形で引数をセットすると、上位コンポーネントでは、あたかもそのコンポーネント自身がマウスイベントを拾ったかのようにイベントをハンドリングすることができます。

```vue:VFCrust.vue
<!-- ここでマウスイベントを拾って、明示的にイベントを発火させる -->
<template>
  <g>
    <rect
      :x="x"
      :y="y"
      :rx="rx"
      :ry="ry"
      :width="width"
      :height="height"
      :fill="fill"
      @mousedown="raiseEvent"
      @mousemove="raiseEvent"
      @mouseup="raiseEvent"
    />
  </g>
</template>

<script>
export default {
//（中略）
//event.typeを第１引数、eventそのものを第２引数として渡すことで、上位に向けて同じイベントを発火できる
  methods: {
    raiseEvent: function(event){
      this.$emit(event.type, event)
    },
  },
//（後略）
}
</script>
```

```vue:VFBlock.vue
<!-- ここにもイベントリスナを記述して上位へ伝達する -->
<template>
  <g
    :x="x"
    :y="y"
  >
    <vf-crust
      :x="block_x"
      :y="block_y"
      @mousedown="raiseEvent($event, item_id)"
      @mousemove="raiseEvent($event, item_id)"
      @mouseup="raiseEvent($event, item_id)"
    />
    <vf-label
      :x="block_x"
      :y="block_y"
    />
    <vf-connector
      :x="block_x"
      :y="block_y"
    />
  </g>
</template>

<script>
import VfCrust from "../atoms/VFCrust.vue"

export default {
  name: "VFBlock",
  components: {
    VfCrust,
  },
  props: {
    item_id: {
      type: Number,
      default: 0
    },
  },
//（中略）
//ここのraiseEventでは、自分自身に付与されたitem_idも渡すようにしてある。
//どのブロックが操作されているのかを上位側で判断できるようにするため。
  methods: {
    raiseEvent: function(event){
      this.$emit(event.type, event, item_id)
    },
  },
//（後略）
}
</script>
```

```vue:VFCanvas.vue
<!-- mousedown, mouseup, mousemove を検出 -->
<!-- このファイルではマウスイベントの種類ごとに実施したい処理が異なるので、イベントハンドラも分けてある -->
<template>
  <div>
    <svg>
      <vf-block
        v-for="block in blocks"
        :key="block.id"
        :x="block.x"
        :y="block.y"
        :item_id="block.item_id"
        @mousedown="execMouseDown"
        @mouseup="execMouseUp"
        @mousemove="execMouseMove"
      />
    </svg>
  </div>
</template>

<script>
import VfBlock from "../molecules/VFBlock.vue"

export default {
  name: "VFCanvas",
  components: {
    VfBlock,
  },
//（中略）
  data() {
    return {
      is_draggable: false,
    }
  },

  methods: {
    execMouseDown: function(event, item_id){
      this.is_draggable = true
      //（略）
    },
    execMouseMove: function(event, item_id){
      //ドラッグを開始していない場合は処理なし
      if (!this.is_draggable) {
        return
      }
      //（略）
    },
    execMouseUp: function(event, item_id){
      this.is_draggable = false
      //（略）
    },
  },
//（後略）
}
</script>
```




#まとめ
今回は、個人開発プロジェクトを進める中で発生した不明点のうち、調べてもなかなかズバリの回答がヒットしなかった部分を記事化してみました。

12月に入ってから偶然アドベントカレンダーの空きを見つけ、勢いで登録してから２週間弱。登録時点では構想だけでコード０行だった状態から実質12時間くらい試行錯誤して作った内容の一部をご紹介しました。

もし、もっと良い方法がありましたらお教え頂けますと幸いです。

最後までご覧いただきありがとうございました！

#おまけ
ドラッグ＆ドロップを実現するには他にも考慮することが多々あります。
リンクを追加する時に最も近いコネクタにスナップさせる方法とか。
（こんな動き）

![VlockFlow_リンクの作成.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/120785/e56b71c7-717a-5474-ec94-9fc5d96314ba.gif)

このあたりのコードはまだかなり汚いのでご紹介できるレベルではないのですが、整理できたらまた記事化できればと考えています。