Vue.js で 2D お絵かき

HTML5 Canvas Library である Fabric.jsVue.js に組み込んでみました.
Web クライアントで 2D 描画をする方法の一つです.


Fabric.js って

Fabric.js は、HTML5 の Canvas を js で扱うためのライブラリです。

2D 図形や描画したものをオブジェクトとして制御することができるようになります。 また、普通の HTML要素じゃちょっと難しいかなレベルの細かい図形描画が可能です。

どんなことができるのかは、 [Fabric.js のDemo] ページあたりを見てください。

Fabric.js の描画要素は javascript object になります。 土台になるキャンバスは、 fabric.Canvas で作成します。 その上に、例えば四角を描きたかったら、 fabric.Rect を作成し、キャンバスに add すると、後は勝手に描画してくれます。

Fabric.js の [イントロダクション] にあるサンプルコードだと、こんな感じです。

// create a wrapper around native canvas element (with id="c")
var canvas = new fabric.Canvas('c');

// create a rectangle object
var rect = new fabric.Rect({
  left: 100,
  top: 100,
  fill: 'red',
  width: 20,
  height: 20
});

// "add" rectangle onto canvas
canvas.add(rect);

ここでは、 [オブジェクトの制御のデモ] に似たようなことを Vue.js でやってみます。

typescript で Vue.js

適当なプロジェクトを作成します。 今回は typescript にします。

$ vue create vue-fabric-test

typescript にするには、オプションを手動で設定していきます。

Typescript サポートを組み込みます。

一点だけ。 typescript を Vue.js に組み込む時に、 component をクラスとして定義するか 、 object として定義するかの2通りから選べます (Use class-style component syntaxの項目)。 ここでは、 object として定義するので、 No にしてます。 それ以外はデフォルトです。

出来上がったら、Fabric.js を組み込みます。

$ npm install fabric --save

$ npm install @types/fabric --save-dev



キャンバス - fabric.Canvas

今回は、あまり複雑な描画はしないので、簡単に fabric のクラスを、Vue.js の component にマッピングすることで実現します。

まず、描画の土台になるキャンバスの準備です。キャンバスは、 HTMLの canvas タグで定義します。

<template>
  <div>
    <canvas id="base-canvas" />
  </div>
</template>

typescript なので、変数等の型定義が必要になります。

<script lang="ts">
import Vue from 'vue';
import { fabric } from "fabric";

interface DataType {
  canvas: fabric.Canvas | undefined;
  background: string;
}

パラメータ類は、 props で渡します。

export default Vue.extend({
  name: "FabricCanvas",
  props: {
    width: Number,
    height: Number,
  },
  data() : DataType {
    return {
      canvas: undefined,
      background: 'lightgray',
    };
  },

実行時 (mounted) になったら、 fabric.Canvas を作って、 data に保存しておきます。 fabric.Canvas の最初の引数は、 HTML側の canvas タグの id です。 2番目は、オプションパラメータになります。

  mounted: function () {
    this.$nextTick(function () {
      const canvas = new fabric.Canvas("base-canvas", {
        width: this.width,
        height: this.height,
        backgroundColor: this.background,
      });
      canvas.preserveObjectStacking = true;
      canvas.stateful = true;

      this.canvas = canvas;
    });
  },
});
</script>

canvas.preserveObjectStacking で、キャンバスに追加したオブジェクトが選択されても、手前にこないようにしています。

canvas.stateful は、今回は使いませんがオブジェクトが、オブジェクトに対するイベントハンドリング時に、操作前の状態が記録されて渡されるようになります。

FabricCanvas の準備できたら、 App.vue を変更して、組み込みます。

<template>
  <div id="app">
    <FabricCanvas :width=600 :height=400 />
  </div>
</template>

<script lang="ts">
import Vue from 'vue';
import FabricCanvas from './components/FabricCanvas.vue';

export default Vue.extend({
  name: 'App',
  components: {
    FabricCanvas
  }
});
</script>

lightgray の背景色のキャンバスが描画されているはずです。

描画オブジェクト - fabric.Rect

次にキャンバス上に四角を描画します。 四角は、 fabric.Rect です。 <template> は空にします。 Canvas 上の描画については、全て fabric 側で行われます。

<template>
<div />
</template>

<script lang="ts">
import Vue, { PropType } from 'vue';
import { fabric } from "fabric";

interface DataType {
  rect: fabric.Rect | null;
}

export default Vue.extend({
  name: "FabricRect",
  props: {
    coord: Array as PropType<number[]>,
    fillColor: String, 
    canvas: Object as PropType<fabric.Canvas>,
    handleCoordChange: Function as PropType<(coord:number[]) => void>,
  },
  data(): DataType {
    return {
      rect: null,
    };
  },
  watch: {
    // eslint-disable-next-line no-unused-vars
    canvas: function (_val, _oldVal) {
      const rect = new fabric.Rect({
        left: this.coord[0],
        top: this.coord[1],
        width: this.coord[2],
        height: this.coord[3],
        fill: this.fillColor,
      });
      this.canvas.add(rect);
      this.rect = rect;
    },
  },
});
</script>

FabricCanvas 側を以下のように変更して、 FabricRect を追加します。

<template>
  <div>
    <canvas id="base-canvas">
      <FabricRect :coord="[100, 100, 200, 200]" :canvas="canvas" fillColor="red"/>
    </canvas>
  </div>
</template>

FabricCanvas の mountedcanvas が生成されたら、 FabricRect の props に渡ってきます。 FabricRect の watch で、 canvas を見ているので、そこで fabric.Rect が作成されることになります。 生成された fabric.Rect はキャンバスに追加されることで描画されるという流れになります。

できあがると、こんな画面になるはずです。キャンバス上に描画した四角をコントローラで制御するインタラクティンブな描画が簡単に実現できます。

ソースコードは、 [github の vue-fabric-test (basic) ] に置いてあります。

イベントハンドリング

基本的な描画はできるようになりました。 fabric では描画要素をユーザが操作することができます。 (もちろん静的に描画するだけってのも可能です)

今回の実装では、 fabric オブジェクトは Vue の値で作成するだけで、その後は両方の座標値などのプロパティは独立しています。 きちんと実装するなら、 Vue のプロパティと fabric オブジェクトのプロパティを双方向バインディングすると良いのですが、ちょっと面倒です。

そこで、簡易的に fabric のイベントハンドラーで Vue 側の値を書き換えるようにしてみます。

最終的なソースコードは、[vue-fabric-test (developブランチ) ] にあります。

fabric は、ユーザ操作後にイベントを発生しますので、ハンドラを定義して、Vue側のプロパティを書き換えるようにします。

FabricRect で fabric オブジェクトを生成した後にハンドラを登録します。

      rect.on('moved', () => {
        const coord = [
          Math.round(rect.left!),
          Math.round(rect.top!),
          Math.round(rect.width! * rect.scaleX!),
          Math.round(rect.height! * rect.scaleY!),
        ];
        this.$emit('coord-change', coord, 'moved');
      });
      rect.on('scaled', () => {
        const coord = [
          Math.round(rect.left!),
          Math.round(rect.top!),
          Math.round(rect.width! * rect.scaleX!),
          Math.round(rect.height! * rect.scaleY!),
        ];
        this.$emit('coord-change', coord, 'scaled');
      });

moved / scaled は、それぞれオブジェクトの移動後、拡大縮小後におきます。 他にもイベントがありますので、きちんとやるなら そちらもハンドルした方が良いですが、今回は省略。 

また、ハンドラの使い方として、 moving / scaling などでユーザの操作中にフックする等も可能です。 一覧については、 [fabric のイベント] あたりを参照。 objects events に分類されている ものがオブジェクトの操作時に起きます。

ハンドラの中身を見ていきます。 fabric の座標値は浮動小数です。 Vue 側では整数値で処理しているので、 Math.round で四捨五入します。

width / height についてですが、 fabric では拡大縮小すると scaleX / scaleY の倍率が変更されるだけになります。 実際の数値を掛け算で求めてから、四捨五入して Vue 側の値を算出します。

$emit でイベントとして送出します。

この記事の最初の方では、座標値を数値の配列 (numnber[])として扱っていましたが、 ここでは、FabricRect の座標値として、 App.vueinterface を定義しています。

export interface rectRecord {
  x: number;
  y: number;
  w: number;
  h: number;
  fill: string;
}

interface DataType {
  rects: rectRecord[];
}

App.vuerects: rectRecord[] を定義して、 FabricCanvas に渡します。 FabricCanvas では、 FabricRectv-for で生成します。

さて、 fabric 側で変更された値は、 FabricRect から coord-change と名前をつけて $emit したのは上で見ました。 それを FabricCanvas で受けて、どの FabricRect から発報されたものか index を追加して、 App に流します。

<template>
  ...
        @coord-change="(coord, event) => $emit('coord-change', index, coord, event)" />
  ...
<template>

App.vue では、それを受けて、 handleCoordChange メソッドを呼び出します。

<template>
  ...
      @coord-change="handleCoordChange" />
  ...
<template>

そこで、 data の内部値を書き換えるようになります。 これで fabric での値の変更を Vue 側に反映することができるようになります。

このスクリーションショットでは、 chrome に Vue.js devtools を組み込んでいます。 devtools を使うと、 chrome の デベロッパーツールから、 Vue の内部プロパティ等をリアルタイムでデバッグできるようになります。 (ただ、開発サーバ上しか動作しません。)

残りとして、Vue 側での値に reactive に fabric を合わせることで双方向バインディングできるようになりますが、そちらは省略します。 ヒントとしては、 fabric オブジェクトに値を set() してから、 setCoord() を使うことで、オブジェクトを変更に合わせて描画させることができます。

reactive なフレームワークを使う利点としては、 Single Source of Truth が実現しやすいことが挙げられるので、是非その辺挑戦してみてください。