Fabric.jsでCanvasのピンチイン ピンチアウトで苦労したのでまとめる

福岡から世界中の"むずかしい"を簡単にする株式会社[diffeasy](https://diffeasy.com)CTOの西[@_takeshi_24](https://twitter.com/_takeshi_24)です。

この記事はアドベントカレンダー「[diffeasyCTO西の24(にし)日連続投稿チャレンジ Advent Calendar 2019](https://qiita.com/advent-calendar/2019/diffeasy24)」の4日目の記事です。

#Nuxt.jsとFabric.jsでCanvasお絵かき
ある開発プロジェクトで、Webアプリケーション内で画像にお絵かきして保存する処理が必要になりました。
利用する端末はiPadで、Safariブラウザを使って、タッチペンで書き込みます。

タッチペンでの記入のほか、画像には細かい文字もあり、ピンチイン・ピンチアウトによるズーム機能も必要です。

前提として、フロントエンドはNuxt.js & TypeScriptで開発しています。

これを素のCanvasで一から開発していくとかなり大変なので、今回は[Fabric.js](http://fabricjs.com/)を利用することにしました。

##Fabric.jsで困ったこと
- Fabric.jsでCanvasに配置した画像の拡大縮小はできるけど、iPadのピンチイン・ピンチアウトで、Canvas全体のズームイン・ズームアウトができない。
- ブラウザの画面自体をズームしても良いけど、固定のメニューは拡大縮小したくない。
- ブラウザの拡大の逆倍率を固定メニューにかけて大きさを変えずに表示することはできるけど、ピンチイン状態で、スクロールすると固定メニューが画面外に動いて隠れてしまう。

色々試してみて結果うまくいったのですが、

- 画面リロード時はうまく動作するけど、画面初回遷移時はうまく動作しなくなる。
- 画面遷移して戻ってくるとうまく動作しなくなる。

などの問題も発生しました。


## ローカル環境のNuxt.jsをiPad実機で動作確認する
何はともあれ、ピンチイン・ピンチアウトなど、iPadでの動きを確認するため、iPadの実機からローカル環境のNuxt.jsアプリケーションに接続が必要です。
ChromeディベロッパーツールでのiPadデバイスシミュレータや、XcodeのiOSシミュレータなどありますが、どれもiPadでの処理を忠実に再現できません。

iPad実機自体は、同じWifi内にMacOSがあれば、MacOSのIPアドレスをブラウザに入力して接続可能です。

IPアドレスで接続するため、Nuxt.jsをlocalhostではなく、IPアドレスで起動する必要があります。

ここでは、ローカルのIPは「192.168.0.132」とします。

package.jsonに以下の内容を追加します。

```
  "config": {
    "nuxt": {
      "host": "192.168.0.132",
      "port": "3000"
    }
  },
```

さらに今回、開発でAuth0を利用していたため、localhost以外のIPアドレスでアクセスするには、http通信ではセキュリティエラーが発生し、繋がりませんでした。

```console
auth0-spa-js must run on a secure origin
```

SSL通信が必要になります。
以降の対応は、Auth0など利用していなければ不要です。

[この辺り](https://weblabo.oscasierra.net/openssl-gencert-1/)を参考にオレオレ証明書を作成し、ssl証明書と、認証キーを読み込みます。

```nuxt.config.ts
import fs from 'fs'
・・・
  server: {
    https: {
      key: fs.readFileSync(path.resolve(__dirname, 'ssl/server.key')),
      cert: fs.readFileSync(path.resolve(__dirname, 'ssl/server.crt'))
    }
  },
```

これで、iPadからhttps://192.168.9.132:3000 にアクセスすることで、ローカルで開発中のNuxt.jsアプリケーションに接続できます。
さらにiPadをUSBでつないで、Safariを起動し、メニューの「開発」を開くと、iPadのSafariで実行中のWebアプリのデバッグも可能です。
<img width="802" alt="iPadSafari接続.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3003/4f1e9eef-bcd5-e4cd-c5ff-83550e3d22a7.png">
<img width="999" alt="iPadSafariデバッグ.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3003/e363b98c-9f98-d9d3-54ad-76d797616ebb.png">

以上で、開発の準備は完了です。

##Nuxt.jsとFabric.jsでCanvasお絵かき完成！
それでは、本題。
Nuxt.jsとFabric.jsで作ったCanvasのお絵かき画面が、iPadのタッチ操作でうまく動作しない問題、色々試行錯誤しましたが、以下の方法で解決しましたので、共有します！

まず、画面のタッチ操作をFabric.jsのCanvasが全部吸収してしまうので、お絵かきモードと、タッチ操作モードを分けて、タッチ操作モードの時は、Canvasの上にdivを被せて、Canvasのタッチイベントが発生しないようにしました。
お絵かきモードの時は、上にかぶせたdivを非表示にします。

*タッチ操作モード時*
<img width="519" alt="スクリーンショット 2019-11-28 23.12.30.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3003/5ed95f49-6509-21dc-0312-5337b593a3a6.png">

*お絵かきモード時*
<img width="520" alt="スクリーンショット 2019-11-28 23.17.13.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3003/3edc05bf-c6c2-287e-85f4-56b3b0ce719c.png">

``class="the-canvas-editor__cover"``のdivが、上で述べたcanvasに被せるdivです。

```vuejs:componentのtemplate
<template>
  <div class="the-canvas-editor">
    <!-- Canvasに被せるdiv -->
    <div v-if="!isEditing" class="the-canvas-editor__cover"></div>
    <!-- 画面上部固定メニュー -->
    <the-canvas-editor-controller
      v-bind="canvasEditorControllerProps"
      @cancel="cancel()"
      @done="saveCanvas()"
    />
    <!-- Fabric.jsのCanvas -->
    <canvas
      id="canvas"
      class="the-canvas-editor__canvas"
      :width="canvasWidth"
      :height="canvasHeight"
    />
    <!-- 画面下部固定メニュー -->
    <div class="the-canvas-editor__toolbar">
      <div class="the-canvas-editor__toolbar__wrap">
        <the-cnavas-tool-bar
          @pen="startPen"
          @color="changeColor"
          @marker="startMarker"
          @eraser="startEraser"
          @select="startSelectObjects"
        />
      </div>
    </div>
  </div>
</template>
```

```vuejs:componentのstyle

<style lang="scss" scoped>
.the-canvas-editor {
  position: relative;

  &__cover {
    position: absolute;
    z-index: 1;
    width: 100%;
    height: 100%;
  }
  &__controller {
    position: absolute;
    z-index: 2;
    width: 100%;
  }
  &__toolbar {
    position: fixed;
    z-index: 2;
    right: 0;
    bottom: 0;
    left: 0;
    padding-right: 20px;
    padding-left: 20px;
  }
}

```

プログラムの主な箇所だけ抜粋しています。

```javascript:vuejs:componentのscript

<script lang="ts">
type DataType = {
  canvas: any;
  isTouching: boolean;
  isEditing: boolean;
  pointX: number;
  pointY: number;
};
export default Vue.extend({
  name: 'TheCanvasEditor',
  data: (): DataType => ({
    canvas: null,
    isTouching: false,
    isEditing: false,
    pointX: 0,
    pointY: 0,
  }),

export default Vue.extend({
  async mounted() {
    this.canvas = new fabric.Canvas('canvas');

    this.$nextTick(function() {
      // レンダリング完了後にイベントリスナーを登録
      window.addEventListener(
        'touchmove',
        this.touchMoveEvent,
        this.passiveMode,
      );
      window.addEventListener('touchend', this.touchEndEvent, this.passiveMode);
      window.addEventListener(
        'orientationchange',
        this.orientationChange,
        this.passiveMode,
      );
    });
  },
  beforeDestroy() {
    window.removeEventListener('touchmove', this.touchMoveEvent);
    window.removeEventListener('touchend', this.touchEndEvent);
    window.removeEventListener('resize', this.orientationChange);
  },
  methods: {
    touchMoveEvent(e) {
      // 画面タッチ移動
      let d0: number = 1;
      let d1: number = 1;
      e.preventDefault();
      if (!this.isEditing && e.touches.length == 1) {
        // スワイプスクロール
        if (!this.isTouching) {
          this.isTouching = true;
          this.pointX = e.touches[0].screenX;
          this.pointY = e.touches[0].screenY;
        } else {
          const fabricPoint: any = new fabric.Point(
            e.touches[0].screenX - this.pointX,
            e.touches[0].screenY - this.pointY,
          );
          this.pointX = e.touches[0].screenX;
          this.pointY = e.touches[0].screenY;
          this.canvas.relativePan(fabricPoint);
        }
      } else if (!this.isEditing && e.touches.length == 2) {
        // ピンチイン・ピンチアウト
        if (!this.isTouching) {
          this.isTouching = true;
          d0 = Math.sqrt(
            Math.pow(e.touches[1].screenX - e.touches[0].screenX, 2) +
              Math.pow(e.touches[1].screenY - e.touches[0].screenY, 2),
          );
        } else {
          d1 = Math.sqrt(
            Math.pow(e.touches[1].screenX - e.touches[0].screenX, 2) +
              Math.pow(e.touches[1].screenY - e.touches[0].screenY, 2),
          );
          const zoomer = d1 / d0 < 100 ? 1 : d1 / d0 / 100;

          const fabricPoint: any = new fabric.Point(
            (e.touches[0].screenX + e.touches[1].screenX) / 2,
            (e.touches[0].screenY + e.touches[1].screenY) / 2,
          );

          if (zoomer === 1) {
            this.canvas.setZoom(1);
            const fabricPoint: any = new fabric.Point(0, 0);
            this.canvas.absolutePan(fabricPoint);
          } else {
            this.canvas.zoomToPoint(fabricPoint, zoomer);
          }
        }
      }
    },
    touchStartEvent(e) {
      // 画面タッチ開始
      if (!this.isEditing && e.touches.length == 1) {
        e.preventDefault();
        this.canvas.setZoom(1);
        const fabricPoint: any = new fabric.Point(0, 0);
        this.canvas.absolutePan(fabricPoint);
      }
      return;
    },
    touchEndEvent(e) {
      // 画面タッチ終了
      this.isTouching = false;
    },
    startEditing() {
      // 編集モード開始
      this.isEditing = true;
    },
    finishEditing() {
      // 編集モード終了
      this.isEditing = false;
    },
  },
});
```

####プログラムのポイント解説
ポイントのみ解説します。

#####canvasに被せるdivの切り替え
- this.isEditingを切り替えることで、canvasに被せるdivの表示非表示を切り替えています。

#####イベントリスナーの登録削除
- mountedの``this.$nextTick(function() {・・・});``の中でイベントリスナーを登録しています。mountedは、全ての子コンポーネントもマウントされていることを保証しません。
$nextTickを利用することで、ビュー全体がレンダリングされるまで待つことができます。
- beforeDestroyで、イベントリスナーを破棄しています。イベントリスナーを破棄しないと、Nuxt.jsで画面遷移してもイベントリスナーが残ったままになります。
beforeDestroyはインスタンスが破棄される直前に呼ばれます。

#####スワイプでスクロール
- touchMoveEventで```e.touches.length == 1```の場合がスワイプ処理。
- ```this.canvas.relativePan(fabricPoint);```で指を移動させた分の相対位置でcanvasを移動します。

#####ピンチイン・ピンチアウト
- touchMoveEventで```e.touches.length == 2```の場合がピンチイン・ピンチアウト処理。
- 指と指の幅から拡大率を計算します。

```javascript
d1 = Math.sqrt(
  Math.pow(e.touches[1].screenX - e.touches[0].screenX, 2) +
    Math.pow(e.touches[1].screenY - e.touches[0].screenY, 2),
);
const zoomer = d1 / d0 < 100 ? 1 : d1 / d0 / 100;
```

- 指と指の中間点から、ピンチイン・ピンチアウトするポイントを計算します。

```javascript
const fabricPoint: any = new fabric.Point(
  (e.touches[0].screenX + e.touches[1].screenX) / 2,
  (e.touches[0].screenY + e.touches[1].screenY) / 2,
);
```
- ```this.canvas.zoomToPoint(fabricPoint, zoomer);```で、ピンチイン・ピンチアウトします。

##まとめ
なんとかFabric.jsを使ったCanvasのピンチイン・ピンチアウトはできるようになりましたが、結構無理やりな部分もあるので、もっとスマートなやり方をご存知の方は、ぜひ教えてください！

引き続き、アドベントカレンダー「[diffeasyCTO西の24(にし)日連続投稿チャレンジ Advent Calendar 2019](https://qiita.com/advent-calendar/2019/diffeasy24)」をよろしくお願いします！

是非Qiitaアカウントか[twitterをフォロー](https://twitter.com/_takeshi_24)していただき、ツッコミやいいね！お願いします！

[#advent_24](https://twitter.com/hashtag/advent_24)のハッシュタグでフィードバックいただけると嬉しいです！