3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

VueAdvent Calendar 2023

Day 16

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

Last updated at Posted at 2023-12-13

主旨

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

g6.gif

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

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

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

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

前提

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

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

手順

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

vue3 プロジェクト作成

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

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

$ 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

起動テスト

$ yarn dev

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

image.png

Canvas プログラミング

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

g6.gif

canvas の設置

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

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

image.png

<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 を取得しています。これらの処理は、ページが表示された最初の1回だけ実行すればよいので mounted() に記述しています。

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

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

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

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

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

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

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

g1.gif

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

<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()関数が呼び出されるようになります。

this.canvas.addEventListener( "mousedown", this.mouseDown );

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

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

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

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

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

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

    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 の位置に矩形を描画すると、少し右下にずれた位置に矩形(白い点)が描かれてしまいます。

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

g2.gif

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

   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() メソッド

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

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

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

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

image.png

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

<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() 関数を下記に示します。

    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" という、やや暗い灰色を指定しています。

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

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

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

g3.gif

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

<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) としたときの、マウスがクリックされたグリッドの座標値が取得できます。

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

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

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

グリッドを塗りつぶす

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

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

    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 することで、右側や下辺の灰色のグリッドを上書きしないようにしています。

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

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

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

<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 は右ボタンが押されたことを意味します。

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

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

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

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

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

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

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

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

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

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

image.png

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

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

<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 で共有できるように、下記のように引数をとるように変更しています。

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

mouseDown() 関数でグリッドを塗りつぶすときに、プレビュー画面側にも白い点を描画するように変更しています。点を打つ座標値は gird.x, 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 ); // 追加
    },

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

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

g5.gif

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

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

    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 要素をクリックした状態にする。

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

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

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

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

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

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

結論

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

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

3
2
0

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
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?