LoginSignup
17
9

More than 3 years have passed since last update.

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

Posted at

福岡から世界中の"むずかしい"を簡単にする株式会社diffeasyCTOの西@_takeshi_24です。

この記事はアドベントカレンダー「diffeasyCTO西の24(にし)日連続投稿チャレンジ Advent Calendar 2019」の4日目の記事です。

Nuxt.jsとFabric.jsでCanvasお絵かき

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

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

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

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

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通信ではセキュリティエラーが発生し、繋がりませんでした。

auth0-spa-js must run on a secure origin

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

この辺りを参考にオレオレ証明書を作成し、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アプリのデバッグも可能です。
iPadSafari接続.png
iPadSafariデバッグ.png

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

Nuxt.jsとFabric.jsでCanvasお絵かき完成!

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

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

タッチ操作モード時
スクリーンショット 2019-11-28 23.12.30.png

お絵かきモード時
スクリーンショット 2019-11-28 23.17.13.png

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

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>
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;
  }
}

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

vuejs

<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の場合がピンチイン・ピンチアウト処理。
  • 指と指の幅から拡大率を計算します。
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,
);
  • this.canvas.zoomToPoint(fabricPoint, zoomer);で、ピンチイン・ピンチアウトします。

まとめ

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

引き続き、アドベントカレンダー「diffeasyCTO西の24(にし)日連続投稿チャレンジ Advent Calendar 2019」をよろしくお願いします!

是非Qiitaアカウントかtwitterをフォローしていただき、ツッコミやいいね!お願いします!

#advent_24のハッシュタグでフィードバックいただけると嬉しいです!

17
9
1

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
17
9