Fabric.jsのオブジェクトとVueの連係

# はじめに

Fabric.js本体やVueのリアクティブな機能を連係する実装について試した。

# 準備

### プロジェクト作成

`vue create`でVue 3 の Composition API を TypeScript で記述するように選択してプロジェクトを作成。

### Lintルール調整

個人的な好みによりLintルールを調整。

<details>
<summary>本記事で前提となるLintルール</summary>

```js:.eslint.js
  // comma-dangleとsemiを追加
  rules: {
    'comma-dangle': ['error', {
      arrays: 'always-multiline',
      objects: 'always-multiline',
      imports: 'never',
      exports: 'never',
      functions: 'never',
    }],
    semi: ['error', 'always'],
    'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
    'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
  },
```

</details>

### App.vue更新

プリセットのコンポーネントとスタイルは除去して検証用のコンポーネントのみにする。

<details>
<summary>更新後のApp.vue</summary>

```vue:App.vue
<template>
  <MyCanvas/>
</template>

<script lang="ts">
import { defineComponent } from 'vue';
import MyCanvas from '@/components/MyCanvas.vue';

export default defineComponent({
  name: 'App',
  components: {
    MyCanvas,
  },
});
</script>
```

※`MyCanvas.vue`については後述

</details>

# 検証
## Fabric.jsインストール

fabric本体と型情報をそれぞれ `npm install` する。

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

## Fabric.jsによる描画

まずは [チュートリアル](http://fabricjs.com/fabric-intro-part-1) に従い四角形を配置してみる。  
※下記ではチュートリアルに従いidによりcanvasを決定しているが、Vueでやるならテンプレート参照を使ったほうがよいかも(後述)

```vue:MyCanvas.vue
<template>
  <canvas id="myCanvas"></canvas>
</template>

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

export default defineComponent({
  setup: () => {
    // create a rectangle object
    const rect = new fabric.Rect({
      left: 200,
      top: 100,
      fill: 'red',
      width: 31,
      height: 31,
    });

    onMounted(() => { // ★1
      // create a wrapper around native canvas element
      const canvas = new fabric.Canvas('myCanvas');

      // "add" rectangle onto canvas
      canvas.add(rect);
    });
  },
});
</script>

<style>
.canvas-container { /* ★2 */
  border: 1px solid grey;
}
</style>
```

- ★1: `fabric.Canvas()` はDOM操作を伴うため `onMounted` 内で実行する必要がある
- ★2: fabricによりcanvas要素がdiv要素によってwrapされる(`.canvas-container`)のでそれにスタイルを定義している

実行結果は以下の通り。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266076/b435446d-a822-2158-acb2-fd902ec5af13.png)

デフォルトで移動・回転・拡大縮小ができるようになっている。
![Animation1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266076/f0e877b5-74c7-b6cc-333d-58ad590c52b0.gif)

### Elementで指定するパターン

Canvasのコンストラクタ引数にはidだけでなくHTMLCanvasElementでの指定もできる。テンプレート参照により取得したElementで指定した方がVueらしい記述になる。

```diff_tsx:setupの抜粋(id指定パターンとの差異)
+   const canvasElm = ref<HTMLCanvasElement>();
    onMounted(() => {
      // create a wrapper around native canvas element
-     const canvas = new fabric.Canvas('myCanvas');
+     const canvas = new fabric.Canvas(canvasElm.value!);

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

+   return {
+     canvasElm,
+   };
```
```diff_vue:templateの抜粋(id指定パターンとの差異)
- <canvas id="myCanvas"></canvas>
+ <canvas ref="canvasElm"></canvas>
```

### 画像の表示と注意点

`fabric.Image`を使用する場合は元となるimg要素が必要になるので`fabric.Canvas`と同様に`onMounted`でインスタンスを作成する。

```diff_tsx:setupの抜粋
+   const img1Elm = ref<HTMLImageElement>();
+   const img2Elm = ref<HTMLImageElement>();
    const canvasElm = ref<HTMLCanvasElement>();
    onMounted(() => {
      // create a wrapper around native canvas element
      const canvas = new fabric.Canvas(canvasElm.value!);

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

+     const img1 = new fabric.Image(img1Elm.value!, { left: 10, top: 20, scaleX: 0.4, scaleY: 0.4 });
+     const img2 = new fabric.Image(img2Elm.value!, { left: 60, top: 60, scaleX: 0.4, scaleY: 0.4 });
+     canvas.add(img1, img2);
    });

    return {
      canvasElm,
+     img1Elm,
+     img2Elm,
    };
```
```diff_vue:templateの抜粋
+ <img ref="img1Elm" src="@/assets/sun.png" class="hidden">
+ <img ref="img2Elm" src="@/assets/moon.png" class="hidden">
  <!-- class="hidden"は当該img要素自体を画面表示させないために付与 -->
```

ただし、上記の記述のみではbase64にコンパイルされた画像にのみ有効だがコンパイルされてない画像はうまく表示することができない。Vueのデフォルト設定ではassetsに配置したとしても一定以上のサイズの画像はコンパイルされずにpublicと同様の扱いになるため注意が必要。

実行結果は以下の通り。(初期状態ではsun.pngが表示されず、次のレンダリングが走ったタイミングで反映される)(←moon.pngはコンパイルされ、sun.pngはコンパイルされていない)
![Animation4.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266076/d9f05558-9198-b5d4-98c1-47bb31225b27.gif)

これを防ぐためには以下のいずれかの処理が必要になる

- コンパイルされるようにwebpackの設定を変更する
- `v-on:load`を使用して画像の読み込み完了を検知してからインスタンスを`add`する(または`renderAll`などで再レンダリングする)
- 画像をpublic配下に置き、img要素経由ではなく`fabric.Image.fromURL`から作成する

#### 参考
https://koooge.com/posts/2021/02/11_vueconfig_webpack/

https://code-enj.app/post/2021-09-07-image-loaded/

### オブジェクトのプロパティについて

オブジェクトのコンストラクタ引数には任意のプロパティを設定可能。([公式ドキュメント:Rect](http://fabricjs.com/docs/fabric.Rect.html)参照)

```diff_tsx
    const rect = new fabric.Rect({
      left: 200,
      top: 100,
      fill: 'red',
      width: 31,
      height: 31,
+     lockRotation: true,
    });
```

また、インスタンスを経由して後から参照・設定することも可能。

```vue:template部分
<button @click="square">正方形にする</button>
```
```ts:script部分
    const square = () => {
      rect.set('scaleY', rect.scaleX);
      canvas.renderAll();
    };
```
<details>
<summary>※上では省略しているがcanvasの宣言をomMountedの外に出している</summary>

```ts:setup全文
  setup: () => {
    // create a rectangle object
    const rect = new fabric.Rect({
      left: 200,
      top: 100,
      fill: 'red',
      width: 31,
      height: 31,
      lockRotation: true,
    });

    let canvas: fabric.Canvas;
    onMounted(() => {
      // create a wrapper around native canvas element
      canvas = new fabric.Canvas('myCanvas', { width: 500, height: 300 });

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

    const square = () => {
      rect.set('scaleY', rect.scaleX);
      canvas.renderAll();
    };

    return {
      square,
    };
  },
```

</details>

Canvasについてもコンストラクタの第2引数に任意のプロパティを設定可能。([公式ドキュメント:Canvas](http://fabricjs.com/docs/fabric.Canvas.html)参照)

```ts
      canvas = new fabric.Canvas('myCanvas', { width: 500, height: 300 });
```

実行結果は以下の通り。（回転不可、移動・拡大/縮小は可能、ボタン押下で拡大率を揃える）
![Animation2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266076/3f06595c-d7ce-68a2-3b57-87eeb6affa7e.gif)

## Vueとの連係

```diff_tsx:script部分
    const rect = new fabric.Rect({
      left: 200,
      top: 100,
      fill: 'red',
      width: 31,
      height: 31,
-     lockRotation: true,
+     hasControls: false, // ◆1
    });
    // ★2
+   const rectPos = reactive({ x: rect.getCenterPoint().x, y: rect.getCenterPoint().y });
+   rect.on('moving', () => { // ★3
+     rectPos.x = rect.getCenterPoint().x;
+     rectPos.y = rect.getCenterPoint().y;
+   });
```
```diff_vue:template部分
<template>
  <canvas id="myCanvas"></canvas>
  <div>
-   <button @click="square">正方形にする</button>
+   (x, y) = ({{rectPos.x}}, {{rectPos.y}})
  </div>
</template>
```

- ◆1: 拡大/縮小が絡むとややこしいのでコントロールを出さないようにしておく
- ★2: リアクティブオブジェクトを作成する
- ★3: Fabric側の機能であるイベント購読機能を利用する(利用可能なイベントは[Rect](http://fabricjs.com/docs/fabric.Rect.html)およびそのスーパークラス[Object](http://fabricjs.com/docs/fabric.Object.html)参照)

実行結果は以下の通り。
![Animation3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266076/e9cf9d15-b2e0-4147-c1b4-9b969cd478a3.gif)

# 参考

http://fabricjs.com/

https://zenn.dev/sa2knight/scraps/a2952f7dd7a3ef

https://v3.ja.vuejs.org/guide/composition-api-template-refs.html