Vue3 で Canvas 入門（ドット絵エディタの作成）

# 主旨

Vue3 で canvas を使うときの書き方についてのメモです。ここでは、下の図のように動作をするドット絵エディタを作ります。

![g6.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266247/4563500d-4f09-9cdb-7eb2-8585878651d4.gif)

- 512x512 サイズの canvas に 16x16 のグリッドを作成し、モノクロ画像を作成できる 16x16 ドット絵エディタ
- 左クリックで点を打ち、右クリックで点を消去
- 16x16 のプレビュー画面をエディタの右下に設置
- 作成した画像をファイルとしてセーブする機能を用意

vue3 + canvas という組み合わせでググったときに、参考になりそうなページがなさそうだったので記事を作成しました。内容は極めて初歩的です。

vue3 を使いこなしている人には、たぶん何も得る情報のない記事です。

Canvas API については下記に MDN のドキュメントがあります。

- [キャンバス API](https://developer.mozilla.org/ja/docs/Web/API/Canvas_API)

# 前提

Windows / MacOS / Linux のいずれでも同じです。ツールのバージョンは多少違ってもたぶん大丈夫です。

```bash
$ node -v
v20.10.0
$ npm -v
10.2.5
$ yarn -v
1.22.21
```

# 手順

vue3 プロジェクトを作成して、トップページに canvas を配置して、ドット絵エディタ的なものを作成します。

# vue3 プロジェクト作成

最小限の構成でプロジェクトを作ります。JavaScript / vue-router なし、UI 系フレームワークなし、Option API でやります。

プロジェクト名は好きに変えて大丈夫です。

```bash
$ yarn create vue
Vue.js - The Progressive JavaScript Framework

√ Project name: ... canvas-vue-sample1
√ Add TypeScript? ... No / Yes
√ Add JSX Support? ... No / Yes
√ Add Vue Router for Single Page Application development? ... No / Yes
√ Add Pinia for state management? ... No / Yes
√ Add Vitest for Unit Testing? ... No / Yes
√ Add an End-to-End Testing Solution? » No
√ Add ESLint for code quality? ... No / Yes
...
$ cd canvas-vue-sample1
$ yarn install
```

# 起動テスト

```bash
$ yarn dev
```
ブラウザで `http://localhost:5173/` を開いて下記のようなページが出たらプロジェクトの作成は成功です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266247/7fe71d4a-7254-8da0-6546-31f3f4e32e52.png)

# Canvas プログラミング

記事冒頭で説明したとおり、16x16 のドット絵を作成できる、ドット絵エディタを作ります。

![g6.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266247/4563500d-4f09-9cdb-7eb2-8585878651d4.gif)

## canvas の設置

まず 512x512 のサイズの canvas を設置します。`ref="main"` として、`vue` 側から `this.$refs.main` として要素を取得できるようにしています。ここは、通常の JavaScript で Canvas を使う場合と異なるところです。

通常の JavaScript なら `id="main"` として `document.getElementById("main")` などとして取得するか、jQuery を使って `$('#main')` などとするところでしょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266247/bf0d733f-00ed-1f62-0f69-143e480e5919.png)

```html
<template>
  <main>
    <canvas ref="main" width="512" height="512"></canvas>
  </main>
</template>

<script>
export default {
  data: () => {
    return {
      canvas: null,
      ctx: null,
    };
  },
  mounted(){
    this.canvas = this.$refs.main;
    this.ctx = this.canvas.getContext('2d');
    
    this.clear();
  },
  methods: {
    clear(){
      this.ctx.fillStyle = "#000000";
      this.ctx.fillRect(0,0,this.canvas.width, this.canvas.height);
    }
  }
}
</script>
```

初期化のコードとして `moumted()` の中で `canvas` 要素の取得と、`getContext('2d')` として描画コンテキスト `ctx` を取得しています。これらの処理は、ページが表示された最初の１回だけ実行すればよいので `mounted()` に記述しています。

なお、`canvas` 要素と描画コンテキストについてはここでは詳しく説明しません。下記を参照してください。

- [Canvas](https://developer.mozilla.org/ja/docs/Web/API/HTMLCanvasElement)
- [描画コンテキスト](https://developer.mozilla.org/ja/docs/Web/API/HTMLCanvasElement/getContext)

初期化のあとで `methods` 内で定義している `clear()` 関数を呼び出して、canvas の背景を黒色で塗りつぶしています。`fillStyle` で塗りつぶしの色を指定し、`fillRect` でサイズを指定した矩形の範囲を塗りつぶします。

`clear()` 関数は初期化の処理以外でも使うことがあるのため、`mounted()` とは分けて `methods` 内に関数を作成しています。

矩形の描画、塗りつぶしについては下記を参照してください。

- [キャンバスでの図形の描画](https://developer.mozilla.org/ja/docs/Web/API/Canvas_API/Tutorial/Drawing_shapes)

色に指定については、下記を参照してください。

- [スタイルと色の適用](https://developer.mozilla.org/ja/docs/Web/API/Canvas_API/Tutorial/Applying_styles_and_colors)

## マウスの左ボタンで canvas に描画する

マウスの左ボタンを押した時に、その位置に白い点を描画するようにします。

![g1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266247/ba92ba4b-0dfb-1445-5eda-c09adb78970e.gif)

以下のプログラムでは「canvas の上でマウスの左ボタンが押された」こと（イベント）をプログラムで取得して、ボタンが押されたときにその位置座標に白い点（3x3の白い矩形）を描画しています。

```javascript
<script>
export default {
  data: () => {
    return {
      canvas: null,
      ctx: null,
    };
  },
  mounted(){
    this.canvas = this.$refs.main;
    this.ctx = this.canvas.getContext('2d');

    // 追加
    this.canvas.addEventListener( "mousedown", this.mouseDown );
  
    this.clear();
  },
  methods: {
    pos(e){ // 追加
      return {
        x: e.clientX - this.canvas.getBoundingClientRect().left,
        y: e.clientY - this.canvas.getBoundingClientRect().top,
      }
    },
    clear(){
      this.ctx.fillStyle = "#000000";
      this.ctx.fillRect(0,0,this.canvas.width, this.canvas.height);
    },
    mouseDown(e){ // 追加
      this.ctx.fillStyle = "#ffffff";
      this.ctx.fillRect( this.pos(e).x, this.pos(e).y, 3, 3 );
    },
  }
}
```

### マウスの左ボタンを押したイベントを取得する

`mounted()` の下記のコードで、マウスのボタンが押されたイベントを取得できるようにしています。このコードを追加することで、マウスのボタンが押されたときに `mouseDown()`関数が呼び出されるようになります。

```javascript
this.canvas.addEventListener( "mousedown", this.mouseDown );
```
`addEventListener()` は、マウスやキーボードなどでユーザが何らかの操作をしたときに、その操作（イベント）が起こった時に呼び出したい関数を登録するという処理をする関数です。ここでは `"mousedown"`、つまり「マウスのボタンが押された」イベントが発生したときに呼び出す関数として `mouseDown()` を登録しています。

一度、特定のイベントに対して関数を登録すると、解除するまでずっと登録されたままになります。つまり、アプリの開始時に一度だけ実行すればよいため `mounted()` に記述しています。

`addEventListener()` で使用できるマウス関連のイベント名の一覧は下記を参照してください。

- [マウスイベント名一覧](https://developer.mozilla.org/ja/docs/Web/API/Element#%E3%83%9E%E3%82%A6%E3%82%B9%E3%82%A4%E3%83%99%E3%83%B3%E3%83%88)

`addEventListener()` の動作そのものについては、下記を参照してください。

- [EventTarget: addEventListener() メソッド](https://developer.mozilla.org/ja/docs/Web/API/EventTarget/addEventListener)

### ボタンが押された位置に矩形を描画する

`addEventListener()` で登録された `mouseDown()` 関数の中で、矩形の描画処理を行います。

```javascript
    pos(e){
      return {
        x: e.clientX - this.canvas.getBoundingClientRect().left,
        y: e.clientY - this.canvas.getBoundingClientRect().top,
      };
    },
    mouseDown(e){
      this.ctx.fillStyle = "#ffffff";
      this.ctx.fillRect( this.pos(e).x, this.pos(e).y, 3, 3 );
    },
```

ボタンが押された座標は `e.clientX`, `e.clientY` で取得できます。しかし、これらの座標はブラウザの画面の左上を `(0,0)` としたときの座標になっていて、`canvas` の内部の座標になっていません。

そのため、下記のように直接 `e.clientX`, `e.clientY` の位置に矩形を描画すると、少し右下にずれた位置に矩形（白い点）が描かれてしまいます。

```javascript
    mouseDown(e){
      this.ctx.fillStyle = "#ffffff";
      this.ctx.fillRect(e.clientX, e.clientY,3,3);
    },
```

![g2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266247/cccee83a-b64a-f6cb-8dbc-3ec06cbbcfd7.gif)

この「ずれ」を補正するため、`pos()` 関数で `canvas` の右上の座標値を使って補正する処理を書いています。

```javascript
   pos(e){
      return {
        x: e.clientX - this.canvas.getBoundingClientRect().left,
        y: e.clientY - this.canvas.getBoundingClientRect().top,
      };
    },
```

canvas の左端の `x` 座標は `getBoundingClientRect().left` として取得することができます。同様に、上辺の `y` 座標は `this.canvas.getBoundingClientRect().top` として取得できます。これらの値を `clientX`, `clientY` から引くことで、`canvas` の左上を `(0,0)` とした相対的な座標値に変換できます。

`getBoundingClientRect()` 関数については下記を参照してください。

[Element: getBoundingClientRect() メソッド](https://developer.mozilla.org/ja/docs/Web/API/Element/getBoundingClientRect)

`e.clientX`, `e.clientY` については下記を参照してください。

- [MouseEvent: clientX プロパティ](https://developer.mozilla.org/ja/docs/Web/API/MouseEvent/clientX)
- [MouseEvent: clientY プロパティ](https://developer.mozilla.org/ja/docs/Web/API/MouseEvent/clientY)

## Canvas に 16x16 のグリッドを描く

作ろうとしているのは 16x16 ドットの絵を描くためのエディタなのですが、キャンバスサイズは 512x512 にしてあるので、このままだと 512x512 の絵ができてしまいます。

そこでまず、canvas に 16x16 のグリッドを描いて仕切ります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266247/823c6b56-6141-87c0-9f84-33b81f1b93d0.png)

グリッドの色は、描画の邪魔にならないように暗い灰色にしてみました。

```javascript
<script>
export default {
  data: () => {
    return {
      canvas: null,
      ctx: null,
      grid: { // 追加
        x: 16,
        y: 16,
      },
    };
  },
  mounted(){
    this.canvas = this.$refs.main;
    this.ctx = this.canvas.getContext('2d');

    this.canvas.addEventListener( "mousedown", this.mouseDown );
   
    this.clear();
  },
  methods: {
    pos(e){
      return {
        x: e.clientX - this.canvas.getBoundingClientRect().left,
        y: e.clientY - this.canvas.getBoundingClientRect().top,
      };
    },
    clear(){
      this.ctx.fillStyle = "#000000";
      this.ctx.fillRect(0,0,this.canvas.width, this.canvas.height);
      this.drawGrid(); // 追加
    },
    drawGrid() // 追加
    {
      this.ctx.strokeStyle = "#404040";
      this.ctx.beginPath();
      for ( let x = 0; x < this.grid.x; x++ ){
        this.ctx.moveTo( x * (this.canvas.width/this.grid.x), 0);
        this.ctx.lineTo( x * (this.canvas.width/this.grid.x), this.canvas.height);
      }
      for ( let y = 0; y < this.grid.y; y++ ){
        this.ctx.moveTo( 0, y * (this.canvas.height/this.grid.y));
        this.ctx.lineTo( this.canvas.width, y * (this.canvas.height/this.grid.y));
      }
      this.ctx.stroke();
    },
    mouseDown(e){
      this.ctx.fillStyle = "#ffffff";
      this.ctx.fillRect( this.pos(e).x, this.pos(e).y, 3, 3 );
    },
  }
}
</script>
```

### 線を描く

グリッドの線の描画は `beginPath()`, `moveTo()`, `liveTo()`, `stroke()` という一連の関数の組み合わせで実現しています。

上記のプログラムは座標の指定がやや複雑になっているので、数値を直接指定して簡略化した `drawGrid()` 関数を下記に示します。

```javascript
    drawGrid()
    {
      this.ctx.strokeStyle = "#404040";
      this.ctx.beginPath();
      for ( let x = 0; x < 16; x++ ){
        this.ctx.moveTo( x * 32, 0);
        this.ctx.lineTo( x * 32, 512);
      }
      for ( let y = 0; y < 16; y++ ){
        this.ctx.moveTo( 0,   y * 32);
        this.ctx.lineTo( 512, y * 32);
      }
      this.ctx.stroke();
```

このコードでグリッドを描画した場合でも、見た目は全く同じになるはずです。座標値の指定が複雑になっているのは、canvas のサイズが変更されたり、グリッドのサイズを変えた時でも、問題なくグリッドが描画されるようにするためです。

以下は、Canvas 上で線を引くコードについての説明です。

Canvas 上の線の描画は塗りつぶす場合とは異なり、「線を引くパス（経路）を `moveTo()` や `liteTo()` で指定して、最後に `stroke()` ですべてのパスを一気に描画する」というように処理を行います。

パスの指定を開始する関数が `beginPath()` です。`moveTo()` は「パスは作らず座標だけ移動させる」という関数です。一方 `liteTo()` は「現在の座標から指定した座標まで直線のパスを作る」という関数です。そして `stroke()` は「作成されているすべてのパスに線を引く」という動作をします。

なお `stroke()` を実行すると、それまでに作成されたパスはすべて削除されます。

`stroke()` で線を引く時の色は `strokeStyle` で指定します。このプログラムでは "#404040" という、やや暗い灰色を指定しています。

線（パス）の描画に関しては、下記を参照してください。

- [CanvasRenderingContext2D.stroke()](https://developer.mozilla.org/ja/docs/Web/API/CanvasRenderingContext2D/stroke)

## マウスをクリックしたグリッドが塗りつぶされるようにする

グリッドは描画できましたが、マウスのボタンをクリックすると、グリッドとは無関係にマウスをクリックした位置に白い点が描画されてしまいます。コードを追加して、マウスがクリックされたグリッドそのものが白く塗りつぶされるように変更します。

![g3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266247/a276b1fa-4836-c677-2c1f-1dc712101c55.gif)

マウスでクリックすると、クリックしたグリッド全体が塗りつぶされるようになっています。

```javascript
<script>
export default {
  data: () => {
    return {
      canvas: null,
      ctx: null,
      grid: {
        x: 16,
        y: 16,
      },
    };
  },
  mounted(){
    this.canvas = this.$refs.main;
    this.ctx = this.canvas.getContext('2d');

    this.canvas.addEventListener( "mousedown", this.mouseDown );
    this.clear();
  },
  methods: {
    pos(e){
      return {
        x: e.clientX - this.canvas.getBoundingClientRect().left,
        y: e.clientY - this.canvas.getBoundingClientRect().top,
      };
    },
    clear(){
      this.ctx.fillStyle = "#000000";
      this.ctx.fillRect(0,0,this.canvas.width, this.canvas.height);
      this.drawGrid();
    },
    drawGrid()
    {
      this.ctx.strokeStyle = "#404040";
      this.ctx.beginPath();
      for ( let x = 0; x < this.grid.x; x++ ){
        this.ctx.moveTo( x * (this.canvas.width/this.grid.x), 0);
        this.ctx.lineTo( x * (this.canvas.width/this.grid.x), this.canvas.height);
      }
      for ( let y = 0; y < this.grid.y; y++ ){
        this.ctx.moveTo( 0, y * (this.canvas.height/this.grid.y));
        this.ctx.lineTo( this.canvas.width, y * (this.canvas.height/this.grid.y));
      }
      this.ctx.stroke();
    },
    getGrid(e){
      const pos = this.pos(e);
      return {
        x: Math.floor(pos.x / (this.canvas.width/this.grid.x) ),
        y: Math.floor(pos.y / (this.canvas.height/this.grid.y) ),
      };
    },
    mouseDown(e){
      const grid = this.getGrid(e); // 追加
      this.ctx.fillStyle = "#ffffff";
      this.ctx.fillRect( grid.x * (this.canvas.width/this.grid.x) + 1, // 変更
        grid.y * (this.canvas.height/this.grid.y) + 1, 
        (this.canvas.width/this.grid.x) - 2,
        (this.canvas.height/this.grid.y) - 2 );
    },
  }
}
</script>
```

### ボタンを押した座標がどのグリッドに含まれるか調べる

マウスの座標を、単純にグリッドの仕切りの数 (16) で割って、整数部分を取り出しています。この処理で、左上のグリッドの座標値を `(0,0)` としたときの、マウスがクリックされたグリッドの座標値が取得できます。

わかりやすいように、上記のコードの変数部分を数値に置き換えたコードを下記に示します。

```javascript
    getGrid(e){
      const pos = this.pos(e);
      return {
        x: Math.floor(pos.x / 16 ),
        y: Math.floor(pos.y / 16 ),
      };
    },
```

`Math.floor()` 関数は、計算結果の整数部分を取り出す関数です。座標の計算をはじめとして、canvas の描画ではよく使われる関数です。詳細は下記を参照してください。

- [Math.floor()](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Math/floor)

### グリッドを塗りつぶす

マウスのボタンがクリックされたグリッドの（論理的な）座標を取得し、その位置にあるグリッド（矩形）を白で塗りつぶすという処理をしています。

元のコードは変数が多くて複雑なので、数値の置き換えたコードを下記に示しておきます。

```javascript
    mouseDown(e){
      const grid = this.getGrid(e);
      this.ctx.fillStyle = "#ffffff";
      this.ctx.fillRect( grid.x * 16 + 1,
        grid.y * 16 + 1, 16 - 2, 16 - 2 );
    },
```

上記のコードでは、すでに描かれている灰色のグリッドの部分を消してしなわないように、塗りつぶしはじめの座標値を x, y とも +1 しています。同様に矩形のサイズについても、幅と高さを両方とも -2 することで、右側や下辺の灰色のグリッドを上書きしないようにしています。

駒かな描画の調整をするときは、描画した結果をスクリーンショットにとるなどして、想定通りの描画になっているかを確認しつつ、修正値を決めるようにします。

## マウスをの右クリックで塗りを解除する

右クリックで塗りをキャンセルできるようにします。実質的に、消しゴムの機能を果たします。

```javascript
<script>
export default {
  data: () => {
    return {
      canvas: null,
      ctx: null,
      grid: {
        x: 16,
        y: 16,
      },
    };
  },
  mounted(){
    this.canvas = this.$refs.main;
    this.ctx = this.canvas.getContext('2d');

    this.canvas.addEventListener( "mousedown", this.mouseDown );
    this.canvas.addEventListener(`contextmenu`, (e) => { // 追加
	    e.preventDefault();
    });
    this.clear();
  },
  methods: {
    pos(e){
      return {
        x: e.clientX - this.canvas.getBoundingClientRect().left,
        y: e.clientY - this.canvas.getBoundingClientRect().top,
      };
    },
    clear(){
      this.ctx.fillStyle = "#000000";
      this.ctx.fillRect(0,0,this.canvas.width, this.canvas.height);
      this.drawGrid();
    },
    drawGrid()
    {
      this.ctx.strokeStyle = "#404040";
      this.ctx.beginPath();
      for ( let x = 0; x < this.grid.x; x++ ){
        this.ctx.moveTo( x * (this.canvas.width/this.grid.x), 0);
        this.ctx.lineTo( x * (this.canvas.width/this.grid.x), this.canvas.height);
      }
      for ( let y = 0; y < this.grid.y; y++ ){
        this.ctx.moveTo( 0, y * (this.canvas.height/this.grid.y));
        this.ctx.lineTo( this.canvas.width, y * (this.canvas.height/this.grid.y));
      }
      this.ctx.stroke();
    },
    getGrid(e){
      const pos = this.pos(e);
      return {
        x: Math.floor(pos.x / (this.canvas.width/this.grid.x) ),
        y: Math.floor(pos.y / (this.canvas.height/this.grid.y) ),
      };
    },
    mouseDown(e){
      const grid = this.getGrid(e);
      if (e.button == 0){ // 追加
        this.ctx.fillStyle = "#FFFFFF";
      }else{
        this.ctx.fillStyle = "#000000";
      }
      this.ctx.fillRect( grid.x * (this.canvas.width/this.grid.x)+1, 
        grid.y * (this.canvas.height/this.grid.y)+1, 
        (this.canvas.width/this.grid.x)-2,
        (this.canvas.height/this.grid.y)-2);
    },
  }
}
</script>

```

## 押されたボタンを判別する

押されたボタンの種類は、`mouseDown(e)` 関数の引数 `e` を使って調べることができます。`e.button == 0` は左ボタン、`e.button == 2` は右ボタンが押されたことを意味します。

```javascript
      if (e.button == 0){ // 追加
        this.ctx.fillStyle = "#FFFFFF";
      }else{
        this.ctx.fillStyle = "#000000";
      }
```

追加した上記のコードでは、左ボタンが押されたときは白く塗りつぶし、それ以外のボタンが押されたときは黒で塗りつぶしています。

ボタンの判別については、下記を参照してください。

- [MouseEvent: button property](https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button)

### 右クリックで表示されるメニューを表示されないようにする

canvas の上で右クリックすると、デフォルトではブラウザの右クリックメニューが表示されます。プログラムの動作そのものに影響はないのですが、人間が操作するときは毎回メニューが表示されると邪魔なので、表示されないようにしておきます。

```javascript
    this.canvas.addEventListener(`contextmenu`, (e) => { // 追加
	    e.preventDefault();
    });
```

デフォルトでは canvas 上で `contextmenu` というイベントが発生すると、ブラウザの右クリックメニューを表示する関数が（ブラウザによって）登録されています（少なくとも chrome, edge の場合）。これを「何もしない」関数に置き換えることで、メニューが表示されることを防ぎます。

`preventDefault()` は「このイベントではこれ以上何の処理もせずに処理を強制的に終了する」という処理を行う関数です。動作の説明は煩雑になるので、ここでの説明は省略します。詳しくは下記を参照してください。

- [Event: preventDefault() メソッド](https://developer.mozilla.org/ja/docs/Web/API/Event/preventDefault)

## 作成したドット絵をプレビューする

16x16 のサイズの canvas を追加して、作成したドット絵委を原寸大で確認できるようにします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266247/f71cca71-bae4-a24b-e294-c47801c93bb2.png)

上の図の右下の、小さな黒い四角形がプレビュー画面です。

追加した canvas は `ref="preview"` として参照できるようにしています。`preview` の初期化も `mounted()` で行っています。マウス関連のイベントは処理する必要がないので、何も登録していません。

```javascript
<template>
  <main>
    <canvas ref="main" width="512" height="512"></canvas>
    <span>...</span> <!-- 追加 -->
    <canvas ref="preview" width="16" height="16"></canvas> <!-- 追加 -->
  </main>
</template>

<script>
export default {
  data: () => {
    return {
      canvas: null,
      ctx: null,

      preview: null, // 追加
      pctx: null,    // 追加

      grid: {
        x: 16,
        y: 16,
      },
    };
  },
  mounted(){
    this.canvas = this.$refs.main;
    this.ctx = this.canvas.getContext('2d');

    this.preview = this.$refs.preview;          // 追加
    this.pctx = this.preview.getContext('2d');  // 追加

    this.canvas.addEventListener( "mousedown", this.mouseDown );
    this.canvas.addEventListener(`contextmenu`, (e) => {
	    e.preventDefault();
    });

    this.clear(this.canvas, this.ctx);   // 変更
    this.clear(this.preview, this.pctx); // 変更

    this.drawGrid(); // 変更
  },
  methods: {
    pos(e){
      return {
        x: e.clientX - this.canvas.getBoundingClientRect().left,
        y: e.clientY - this.canvas.getBoundingClientRect().top,
      };
    },
    clear(canvas, ctx){ // 変更
      ctx.fillStyle = "#000000";
      ctx.fillRect(0,0,canvas.width, canvas.height); // 変更
    },
    drawGrid()
    {
      this.ctx.strokeStyle = "#404040";
      this.ctx.beginPath();
      for ( let x = 0; x < this.grid.x; x++ ){
        this.ctx.moveTo( x * (this.canvas.width/this.grid.x), 0);
        this.ctx.lineTo( x * (this.canvas.width/this.grid.x), this.canvas.height);
      }
      for ( let y = 0; y < this.grid.y; y++ ){
        this.ctx.moveTo( 0, y * (this.canvas.height/this.grid.y));
        this.ctx.lineTo( this.canvas.width, y * (this.canvas.height/this.grid.y));
      }
      this.ctx.stroke();
    },
    getGrid(e){
      const pos = this.pos(e);
      return {
        x: Math.floor(pos.x / (this.canvas.width/this.grid.x) ),
        y: Math.floor(pos.y / (this.canvas.height/this.grid.y) ),
      };
    },
    mouseDown(e){
      const grid = this.getGrid(e);
      if (e.button == 0){ // 追加
        this.ctx.fillStyle = "#FFFFFF";
        this.pctx.fillStyle = "#FFFFFF"; // 追加
      }else{
        this.ctx.fillStyle = "#000000";
        this.pctx.fillStyle = "#000000"; // 追加
      }
      this.ctx.fillRect( grid.x * (this.canvas.width/this.grid.x)+1, 
        grid.y * (this.canvas.height/this.grid.y)+1, 
        (this.canvas.width/this.grid.x)-2,
        (this.canvas.height/this.grid.y)-2);
      this.pctx.fillRect( grid.x, grid.y, 1, 1 ); // 追加
    },
  }
}
</script>
```

canvas を黒で塗りつぶす関数は、main と preview で共有できるように、下記のように引数をとるように変更しています。

```javascript
    clear(canvas, ctx){ // 変更
      ctx.fillStyle = "#000000";
      ctx.fillRect(0,0,canvas.width, canvas.height); // 変更
    },
```

`mouseDown()` 関数でグリッドを塗りつぶすときに、プレビュー画面側にも白い点を描画するように変更しています。点を打つ座標値は `gird.x`, `grid.y` をそのまま使っています。

```javascript
    mouseDown(e){
      const grid = this.getGrid(e);
      if (e.button == 0){ // 追加
        this.ctx.fillStyle = "#FFFFFF";
        this.pctx.fillStyle = "#FFFFFF"; // 追加
      }else{
        this.ctx.fillStyle = "#000000";
        this.pctx.fillStyle = "#000000"; // 追加
      }
      this.ctx.fillRect( grid.x * (this.canvas.width/this.grid.x)+1, 
        grid.y * (this.canvas.height/this.grid.y)+1, 
        (this.canvas.width/this.grid.x)-2,
        (this.canvas.height/this.grid.y)-2);
      this.pctx.fillRect( grid.x, grid.y, 1, 1 ); // 追加
    },
```

## 作成したドット絵をダウンロードする

ダウンロードするためのボタンを設置して、にそのボタンを押すと `preview` の canvas の内容を画像に変換し、ダウンロードできるようにします。

![g5.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266247/06a3422b-aca9-ab25-bc82-785f95920221.gif)

ダウンロードするためのボタンの追加と、ダウンロードするための関数 `saveImage()` を追加しています。

```javascript
<template>
  <main>
    <canvas ref="main" width="512" height="512"></canvas>
    <span>...</span> 
    <canvas ref="preview" width="16" height="16"></canvas> 
    <span>...</span> <!-- 追加 -->
    <button ref="download">download</button> <!-- 追加 -->
  </main>
</template>

<script>
export default {
  data: () => {
    return {
      canvas: null,
      ctx: null,

      preview: null,
      pctx: null,

      download: null, // 追加

      grid: {
        x: 16,
        y: 16,
      },
    };
  },
  mounted(){
    this.canvas = this.$refs.main;
    this.ctx = this.canvas.getContext('2d');

    this.preview = this.$refs.preview;
    this.pctx = this.preview.getContext('2d');

    this.canvas.addEventListener( "mousedown", this.mouseDown );
    this.canvas.addEventListener(`contextmenu`, (e) => {
	    e.preventDefault();
    });

    this.download = this.$refs.download; // 追加
    this.download.addEventListener( "click", this.saveImage );  // 追加

    this.clear(this.canvas, this.ctx);
    this.clear(this.preview, this.pctx); 

    this.drawGrid(); 
  },
  methods: {
    pos(e){
      return {
        x: e.clientX - this.canvas.getBoundingClientRect().left,
        y: e.clientY - this.canvas.getBoundingClientRect().top,
      };
    },
    clear(canvas, ctx){
      ctx.fillStyle = "#000000";
      ctx.fillRect(0,0,canvas.width, canvas.height);
    },
    drawGrid()
    {
      this.ctx.strokeStyle = "#404040";
      this.ctx.beginPath();
      for ( let x = 0; x < this.grid.x; x++ ){
        this.ctx.moveTo( x * (this.canvas.width/this.grid.x), 0);
        this.ctx.lineTo( x * (this.canvas.width/this.grid.x), this.canvas.height);
      }
      for ( let y = 0; y < this.grid.y; y++ ){
        this.ctx.moveTo( 0, y * (this.canvas.height/this.grid.y));
        this.ctx.lineTo( this.canvas.width, y * (this.canvas.height/this.grid.y));
      }
      this.ctx.stroke();
    },
    getGrid(e){
      const pos = this.pos(e);
      return {
        x: Math.floor(pos.x / (this.canvas.width/this.grid.x) ),
        y: Math.floor(pos.y / (this.canvas.height/this.grid.y) ),
      };
    },
    mouseDown(e){
      const grid = this.getGrid(e);
      if (e.button == 0){
        this.ctx.fillStyle = "#FFFFFF";
        this.pctx.fillStyle = "#FFFFFF";
      }else{
        this.ctx.fillStyle = "#000000";
        this.pctx.fillStyle = "#000000";
      }
      this.ctx.fillRect( grid.x * (this.canvas.width/this.grid.x)+1, 
        grid.y * (this.canvas.height/this.grid.y)+1, 
        (this.canvas.width/this.grid.x)-2,
        (this.canvas.height/this.grid.y)-2);
      this.pctx.fillRect( grid.x, grid.y, 1, 1 );
    },
    saveImage(){ // 追加
      const link = document.createElement("a");          
      link.href = this.preview.toDataURL("image/png");
      link.download = "dot.png";
      link.click();
    }
  }
}
</script>
```

## 画像のセーブ

canvas を画像ファイルとしてダウンロードする方法はいくつかありますが、このアプリでは下記の記事の方法を採用してみました。

- [【Javascript】ボタンを押すとcanvasを画像としてダウンロード](https://qiita.com/lookman/items/d93dd62a41f17a4d2de8)
- [【JavaScript】 Canvasの画像をPNG/JPEGとして保存（ダウンロード）する](https://note.affi-sapo-sv.com/js-canvas-download.php)
 
```javascript
    saveImage(){
      const link = document.createElement("a");          
      link.href = this.preview.toDataURL("image/png");
      link.download = "dot.png";
      link.click();
    }
```

上記のプログラムは、次のようにして画像のセーブを実現しています。

- リンクを作るための `a` 要素を生成する（画面上には表示されない）。
- `preview` canvas の内容を png 形式に変換し、変換したデータへアクセスするための URL を生成する。
- 生成した URL を、先に生成した `a` 要素の `href` プロパティに設定する。
- ダウンロードファイル名を `dot.png` に設定する。
- 生成した `a` 要素をクリックした状態にする。

画面上に表示されない、画像をダウンロードできるリンクを生成して、それを（仮想的に）クリックすることで、リンク先のデータ（つまりは画像データ）をダウンロードできます。

コードで使われている関数については、それぞれ下記を参照してください。

- [Document.createElement()](https://developer.mozilla.org/ja/docs/Web/API/Document/createElement)
- [HTMLCanvasElement: toDataURL() メソッド](https://developer.mozilla.org/ja/docs/Web/API/HTMLCanvasElement/toDataURL)
- [HTMLElement: click() メソッド](https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/click)

ここまでのコードで、冒頭で列挙したすべての機能が実現できています。

# canvas 関連のコードを別の js ファイルに分割する

エディタ関連の機能を別の js ファイルに書き出して、vue ファイルから `import` で読み込むようにします。このようにすると、vue ファイルの肥大化を防ぐことができます。

`src/components/DotEditor.js` というファイルを作成し、下記のコードを記述します。エディタ関連のコードを `class DotEditor` としてまとめて `export` しています。

```src/components/DotEditor.js
export class DotEditor {
  constructor(main, preview, download)
  {

    this.canvas = main;
    this.preview = preview;

    this.ctx = this.canvas.getContext('2d');
    this.pctx = this.preview.getContext('2d');

    this.canvas.addEventListener( "mousedown", (e) => {
      this.mouseDown(e)}
    );
    this.canvas.addEventListener(`contextmenu`, (e) => {
	    e.preventDefault();
    });

    this.download = download;
    this.download.addEventListener( "click", () => {
      this.saveImage();
    });

    this.grid = {
      x: 16,
      y: 16,
    };

    this.clear(this.canvas, this.ctx);
    this.clear(this.preview, this.pctx); 

    this.drawGrid(); 
  }

  pos(e){
    return {
      x: e.clientX - this.canvas.getBoundingClientRect().left,
      y: e.clientY - this.canvas.getBoundingClientRect().top,
    };
  }

  clear(canvas, ctx){
    ctx.fillStyle = "#000000";
    ctx.fillRect(0,0,canvas.width, canvas.height);
  }

  drawGrid()
  {
    this.ctx.strokeStyle = "#404040";
    this.ctx.beginPath();
    for ( let x = 0; x < this.grid.x; x++ ){
      this.ctx.moveTo( x * (this.canvas.width/this.grid.x), 0);
      this.ctx.lineTo( x * (this.canvas.width/this.grid.x), this.canvas.height);
    }
    for ( let y = 0; y < this.grid.y; y++ ){
      this.ctx.moveTo( 0, y * (this.canvas.height/this.grid.y));
      this.ctx.lineTo( this.canvas.width, y * (this.canvas.height/this.grid.y));
    }
    this.ctx.stroke();
  }

  checkGrid(e){
    const pos = this.pos(e);
    return {
      x: Math.floor(pos.x / (this.canvas.width/this.grid.x) ),
      y: Math.floor(pos.y / (this.canvas.height/this.grid.y) ),
    };
  }

  mouseDown(e){
    console.log( this );
    const grid = this.checkGrid(e);
    if (e.button == 0){
      this.ctx.fillStyle = "#FFFFFF";
      this.pctx.fillStyle = "#FFFFFF";
    }else{
      this.ctx.fillStyle = "#000000";
      this.pctx.fillStyle = "#000000";
    }
    this.ctx.fillRect( grid.x * (this.canvas.width/this.grid.x)+1, 
      grid.y * (this.canvas.height/this.grid.y)+1, 
      (this.canvas.width/this.grid.x)-2,
      (this.canvas.height/this.grid.y)-2);
    this.pctx.fillRect( grid.x, grid.y, 1, 1 );
  }

  saveImage(){
    const link = document.createElement("a");          
    link.href = this.preview.toDataURL("image/png");
    link.download = "dot.png";
    link.click();
  }
}
```

`App.vue` 側は `DotEditor.js` を `import` して `new` するときに `main`, `preview`, `download` の要素をそれぞれコンストラクタに渡しています。

```App.vue
<template>
  <main>
    <canvas ref="main" width="512" height="512"></canvas>
    <span>...</span> 
    <canvas ref="preview" width="16" height="16"></canvas> 
    <span>...</span>
    <button ref="download">download</button>
  </main>
</template>

<script>
import {DotEditor} from './components/DotEditor';
export default {
  data: () => {
    return {
      editor: null,
    };
  },
  mounted(){
    this.editor = new DotEditor( this.$refs.main, this.$refs.preview, this.$refs.download );
  },
}
</script>
```

これで、イベント周りを含めて、canvas 関連のコードはすべて `DotEditor.js` 内で完結して動きます。

コードを分割してクラス化する上での注意点は下記のとおりです。

- `App.vue` の `data` セクションに記述されていた変数は、constructor の内部で初期化する必要がある（`this.grid`, `this.canvas` など）
- `addEventListener()` の第二引数はアロー関数にして、その関数内でコールバック関数を呼ぶ。こうしないと、コールバック関数内で `this` を参照しても、インスタンスではなく呼び出し元（たとえば `canvas` (main)） を参照してしまう。

二番目の問題については、下記に詳しく書かれています。

- [JavaScriptのコールバックで クラスthis が参照できないの解決策](https://pisuke-code.com/javascript-class-this-in-callback/)

これは class でコールバックを使うときによくハマる罠のひとつです（むしろ vue だとコールバックが問題なく動くのがすごい）。

# 結論

以上で、vue で canvas を使うときの注意点はだいたい網羅したつもりです。composition API で書く場合でも基本は同じです。

なお Nuxt.js で SSR を有効にしているときは、`DotEditor.client.js` などとして、canvas 関連のコードが常に client 側で実行されるように明示的に指定する必要があります。