Blog

TypeScript + esbuild + Vue でお絵描きパッドを作る

安齋 祐樹 技術ブログ

お絵描きツールを大昔に個人ブログでお遊びで作成したことがあり、それのリバイバル記事です。

canvas を使ってお絵かきツールを作る方法と仕組み

絵が下手...!!

2016年の記事は Vanilla JS でゴリゴリ描いていますが、せっかくなので「TypeScript + esbuild + Vue」の組み合わせで作成してみました。 Vue なので TypeScript とそこまで相性は良くないんですが、JS 部分は結構いい感じにサジェストしてくれます。

Vue は Composition API を採用です。

お絵描きパッド

色を変えたり、ペンの太さ、消しゴムなんかもあります。

Chromeであれば、右クリックでそのまま描いた絵を保存出来ちゃいます。

ディレクトリ構成

src
 ├ js
 │ ├ components
 │ │ ├ Canvas.vue
 │ │ ├ Btn.vue
 │ │ ├ Color.vue
 │ │ ├ Size.vue
 │ │ └ Delete.vue
 │ ├ store
 │ │ ├ color.ts
 │ │ ├ size.ts
 │ │ └ context.ts
 │ ├ App.vue
 │ ├ app.ts
 │ └ reload.ts
 └ index.html
main.ts
package.json

今回はコンポーネント間の情報の共有に store ライブラリを使っています。
tsconfig は今回は入れてませんが好みの設定でいいかと思います。

package.json

{
  •••

  "scripts": {
    "dev": "tsx main.ts dev",
    "build": "tsx main.ts build"
  },
  "devDependencies": {
    "@types/node": "^22.7.5",
    "cheerio": "^1.0.0",
    "esbuild": "^0.24.0",
    "esbuild-plugin-vue3": "^0.4.2",
    "html-minifier": "^4.0.0",
    "tsx": "^4.19.1",
    "typescript": "^5.6.2"
  },
  "dependencies": {
    "pinia": "^2.2.4",
    "vue": "^3.5.11"
  }
}

必要そうな箇所だけ抜粋。

esbuild

今回の JS のビルドには esbuild を使ってみます。高速で build してくれるのはもちろん、出た当初はなかったんですが watch 機能やオートリロードの機能なんかも追加されたので便利です。

esbuild

ただ単独では Vue のトランスパイルまではしてくれないので、Vue 用のパッケージを追加します。

esbuild と webpack の違いは「設定ファイル」のあるなしです。esbuild は node で esbuild のメソッドを利用しながら書いていくのでブラックボックス的なところが少なく、シンプルに使えるかなと思ってます。

esbuild-plugin-vue3

似たようなことができるパッケージは他にも色々ありました。
1行ぐらい足すだけで、いい感じに Vue を JS に変換してくれます。今回は HTML の自動生成もしたので、追加で「cheerio」「html-minifier」が必要だと言われたので入れています。 Vue のトランスパイルだけならこれらは要りません。

tsx

TypeScript をそのまま実行してくれる便利パッケージです。 有名どころは ts-node だと思いますが、あまり安定感がない気がしているので、今回は tsx を使います。
拡張子と同じ名称なのが分かりにくいですが、あまり関係はないみたいです。

pinia

今回の store ライブラリ。Vue 公式でも勧められてるみたいです。

pinia

Composition API のような感じで書けるので使いやすかったです。

scripts

tsx を入れているので、

tsx main.ts dev
tsx main.ts build

と「node」ではなく「tsx」で始めれば TypeScript を JS にそのまま変換し実行してくれます。
行の最後の「dev」と「build」はコマンドライン引数です。main.ts で使っています。

main.ts

import esbuild from "esbuild";
import vuePlugin from "esbuild-plugin-vue3";

// コマンド引数の取得
const mode = process.argv[2] !== 'build' ? 'dev' : 'build';

const context = await esbuild.context({
  // dev と build でエントリーポイントを分ける、dev はオートリロードファイルを作成
  entryPoints: mode === 'build' ? ["src/js/app.ts"] : ["src/js/app.ts", "src/js/reload.ts"],
  bundle: true,
  outdir: "dist/assets/",
  plugins: [vuePlugin({
    // index.html の自動作成
    generateHTML: {
      sourceFile: 'src/index.html',
      outFile: 'dist/index.html',
      pathPrefix: '/assets/',
    },
  })],
  minify: mode === 'build',
  sourcemap: mode === 'dev',
});

// dev は watch モード 
if (mode === 'dev') {
  console.log('----- watch -----');
  await context.watch();

  const { port } = await context.serve({
    servedir: 'dist',
  });

  console.log(`dev: http://localhost:${port}/`)
} else {
  context.rebuild();
  context.dispose();
}

コメントにほぼほぼ書いていますが、先程の scripts の dev と build で処理を分けています。
dev 時は watch したいのと、sourceMap が欲しいのと、minifyがいらないので。

const { host, port } = await context.serve({
  servedir: 'dist',
});

上記コードだけで web サーバが立つので、watch 環境は出来上がりです。簡単!

VuePlugin の箇所は

plugins: [vuePlugin()],

だけで基本動きます。
今回は自動で HTML も作りたかったので generateHTML してみました。 生成される HTML は以下。

src/index.html

<meta name="viewport" content="width=device-width, initial-scale=1.0">
<div id="app"></div>

中身はこれだけです。
dist には

<html>
  <head>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="/assets/app.css">
  </head>
  <body>
    <div id="app"></div>
    <script src="/assets/app.js"></script>
    <script src="/assets/reload.js"></script>
  </body>
</html>

な感じで、JS と CSS の読み込みが入った状態で生成されます。JS ファイルが増えればその分 HTML 側に勝手に付け足してくれる感じです!

esbuild-plugin-vue3

src/js/reload.ts

// live reload
new EventSource("/esbuild").addEventListener("change", () => location.reload());

watch は出来たのであとはライブリロードです。ファイルを編集したら勝手にリロードしてくれる、webpack でいう browserSync。

esbuild が /esbuild にプッシュサーバを立ててくれて、reload.ts がそのプッシュを受け取って reload してくれる、という流れみたいです。簡単!

参考:https://esbuild.github.io/api/#live-reload

src/js/app.ts

import { createApp } from "vue";
import App from "./App.vue";
import { createPinia } from "pinia";

const app = createApp(App);
const pinia = createPinia();

app.use(pinia);
app.mount("#app");

通常の Vue のアップファイルとほぼ同じかなと思いますが、今回は pinia という store ライブラリを使っているので、それを加えています。

src/js/App.vue

<script setup lang="ts">
import Canvas from "./components/Canvas.vue";
import Color from "./components/Color.vue";
import Size from "./components/Size.vue";
import Delete from "./components/Delete.vue";
</script>

<template>
  <div class="wrap">
    <div class="menu">
      <div class="menuItem">
        <Color />
        <Size />
      </div>
      <div class="menuItem">
        <Delete />
      </div>
    </div>
    <Canvas />
  </div>
</template>

<style scoped>
.wrap {
  max-width: 940px;
  margin: 0 auto;
  padding: 0 20px;
}

.menu {
  display: flex;
  justify-content: space-between;
  margin: 0 0 10px;
}

.menuItem {
  display: flex;
  gap: 10px;
}
</style>

<style>
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
</style>

基本 CSS は scoped を採用しています。
各コンポーネントの説明は以下で。

src/js/components/Canvas.vue

<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useContextStore } from "../store/context";
import { useColorStore } from "../store/color";
import { useSizeStore } from "../store/size";

const canvas = ref<HTMLCanvasElement>();

// ペンの状態
const state = ref<"stop" | "draw">("stop");

// ペンの座標の一次保存
const x = ref<number>(0);
const y = ref<number>(0);

// ペン色、ペンサイズ、モードは store 化
const colorStore = useColorStore();
const sizeStore = useSizeStore();
const contextStore = useContextStore();

// ポインターの押下
const handleDrawStart = (event: PointerEvent) => {
  if (canvas.value && event.button === 0 && event.buttons === 1) {
    state.value = "draw";
    const rect = canvas.value.getBoundingClientRect();
    const moveX = event.clientX - rect.left;
    const moveY = event.clientY - rect.top;

    draw(moveX, moveY);
  }
};

// ポインターの移動
const handleDrawMove = (event: PointerEvent) => {
  event.preventDefault();
  if (
    canvas.value &&
    state.value === "draw" &&
    event.button === -1 &&
    event.buttons === 1
  ) {
    const rect = canvas.value.getBoundingClientRect();
    const moveX = event.clientX - rect.left;
    const moveY = event.clientY - rect.top;

    draw(moveX, moveY);
  }
};

// ポインターの押下終了
const handleDrawEnd = () => {
  state.value = "stop";
  x.value = 0;
  y.value = 0;
};

// ポインターが canvas から外れる
const handleDrawLeave = () => {
  x.value = 0;
  y.value = 0;
};

// SP時のキャンバス上のスワイプ判定で画面がスクロールするのを防ぐ
const handleMovePrevent = (event: TouchEvent) => {
  event.preventDefault();
};


const draw = (moveX: number, moveY: number) => {
  const context = contextStore.context;
  if (canvas.value && context) {
    // 描きモードか削除モードかを指定
    context.globalCompositeOperation = colorStore.type;

    context.beginPath();
    // 描いている途中か描き始めかで場合分け
    if (x.value && y.value) {
      context.moveTo(x.value, y.value);
    } else {
      context.moveTo(moveX, moveY);
    }

    context.lineTo(moveX, moveY);
    context.lineCap = "round";
    context.lineWidth = sizeStore.size;
    context.strokeStyle = colorStore.color;
    context.stroke();

    // 移動後の座標を一時保存
    x.value = moveX;
    y.value = moveY;
  }
};

onMounted(() => {
  if (canvas.value) {
    canvas.value.width = canvas.value.getBoundingClientRect().width;
    canvas.value.height = canvas.value.getBoundingClientRect().height;
    const context = canvas.value.getContext("2d") as CanvasRenderingContext2D;
    contextStore.contextSet(context);
  }

  // ポインターの押下が終了イベントはキャンバス外でもキャッチする
  window.addEventListener("pointerup", () => {
    handleDrawEnd();
  });
});
</script>

<template>
  <canvas 
    ref="canvas" 
    class="canvas" 
    @pointerdown="handleDrawStart" 
    @pointermove="handleDrawMove" 
    @pointerleave="handleDrawLeave" 
    @touchmove="handleMovePrevent" 
  />
</template>

<style scoped>
.canvas {
  aspect-ratio: 900 / 600;
  display: block;
  width: 100%;
  border: 1px solid #999;
}
</style>

今回のメインのコンポーネントです。

基本のイベントは

PCであれば
右クリックの押下 → マウスの移動 → 右クリックの押下終了

を検知し、それに基づき canvas 要素に線を引いています。
pointerMove イベントはブラウザのフレーム数に依存するんだと思いますが、その瞬間瞬間で座標を返してくれますので、それを線で繋ぐ。
瞬間瞬間を繋げばそれなりに滑らかな曲線になる、と言った感じの作りです。

pointer のイベントを採用したので、マウス、タッチ、タッチペン(未検証ですが)もいけます。

onMounted(() => {
  if (canvas.value) {
    canvas.value.width = canvas.value.getBoundingClientRect().width;
    canvas.value.height = canvas.value.getBoundingClientRect().height;
    const context = canvas.value.getContext("2d") as CanvasRenderingContext2D;
    contextStore.contextSet(context);
  }

  •••

の箇所である程度レスポンシブ対応をしています。「ある程度」という理由は後述。

各コンポーネント間で共有が必要な state(ref 値)は store 管理です。

src/js/components/Btn.vue

<script setup lang="ts">
const props = defineProps<{
  click: (payload: MouseEvent) => void;
  value?: string | number;
  dataActive?: boolean;
  bgColor?: string;
}>();
</script>

<template>
  <button 
    class="btn" 
    @click="props.click" 
    :value="props.value" 
    :data-active="props.dataActive" 
    :style="props.bgColor ? `--bgColor: ${props.bgColor}` : ''"
  >
    <slot />
  </button>
</template>

<style scoped>
.btn {
  aspect-ratio: 1 / 1;
  width: 40px;
  border: 0;
  cursor: pointer;
  background-color: #ddd;

  @media (width < 768px) {
    width: 22px;
    font-size: 10px;
  }

  &[data-active="true"] {
    background-color: var(--bgColor);
    color: #fff;
  }
}
</style>

キャンバス上部のボタンの共通コンポーネントです。 アクティブ時に色を変えたかったので、CSSの変数で読み込んでいますが、Vue の style 内で JS の変数を使えるようになってるみたいなので、それでも良かったかもしれないです。

【Vue3.2】styleタグ内でJavaScript変数をバインドできる

見た目はお好みで。

src/js/components/Color.vue

<script setup lang="ts">
import Btn from "./Btn.vue";
import { useColorStore } from "../store/color";

const colorStore = useColorStore();

const handleColorChange = (event: MouseEvent) => {
  if (event.currentTarget) {
    const value = (event.currentTarget as HTMLButtonElement).value;

    // 消しゴムかどうかで場合分け
    if (value !== "eraser") {
      colorStore.typeChange("source-over");
      colorStore.colorChange(value);
    } else {
      colorStore.typeChange("destination-out");
      colorStore.colorChange("");
    }
  }
};

const colorList = [
  { name: "黒", color: "#333" },
  { name: "赤", color: "#ff4848" },
  { name: "青", color: "#3d6aff" },
  { name: "緑", color: "#37c36b" },
];
</script>

<template>
  <Btn 
    v-for="item in colorList" 
    :click="handleColorChange" 
    :value="item.color" 
    :dataActive="colorStore.color === item.color" 
    :bgColor="item.color"
  >
    {{ item.name }}
  </Btn>
  <Btn 
    :click="handleColorChange" 
    value="eraser" 
    :dataActive="colorStore.type === 'destination-out'" 
    bgColor="#000"
  >
    消
  </Btn>
</template>

store に color の値と、globalCompositeOperation の値を入れています。

CanvasRenderingContext2D

globalCompositeOperation はキャンバスに線などを引くときの「モード」を切り替える感じのイメージで、線と線が重なったときの重なり順を制御する仕組みです。
今回はこれを通常の「source-over(後から書いたほうが上になる)」と「destination-out(重ならない箇所が残る、結果的に重なったところが消える)」を切り替えることで、「ペン」と「消しゴム」を切り替えます。

src/js/components/Size.vue

<script setup lang="ts">
import Btn from "./Btn.vue";
import { useSizeStore } from "../store/size";

const sizeStore = useSizeStore();

const handleSizeChange = (event: MouseEvent) => {
  if (event.currentTarget) {
    const value = (event.currentTarget as HTMLButtonElement).value;
    sizeStore.sizeChange(Number(value));
  }
};

const sizeList = [
  { name: "細", size: 5 },
  { name: "普", size: 10 },
  { name: "太", size: 20 },
  { name: "極", size: 40 },
];
</script>

<template>
  <Btn 
    v-for="item in sizeList" 
    :click="handleSizeChange" 
    :value="item.size" 
    :dataActive="sizeStore.size === item.size" 
    bgColor="#333"
  >
    {{ item.name }}
  </Btn>
</template>

ペンの太さを切り替えるボタン。

先のカラーより太さだけの制御になるのでシンプルです。

src/js/components/Delete.vue

<script setup lang="ts">
import Btn from "./Btn.vue";
import { useContextStore } from "../store/context";

const contextStore = useContextStore();

const handleDelete = () => {
  contextStore.context?.clearRect(
    0,
    0,
    contextStore.context?.canvas.width,
    contextStore.context?.canvas.height,
  );
};
</script>

<template>
  <Btn :click="handleDelete">×</Btn>
</template>

全消しボタン。

clearRect でキャンバスを空っぽに出来ますのでそれを使います。

src/js/store/color.ts

import { ref } from "vue";
import { defineStore } from "pinia";

export const useColorStore = defineStore("color", () => {
  const color = ref<string>("#333");
  const type = ref<GlobalCompositeOperation>("source-over");

  const colorChange = (value: string) => {
    color.value = value;
  };

  const typeChange = (value: GlobalCompositeOperation) => {
    type.value = value;
  };

  return { color, type, colorChange, typeChange };
});

composition API 風にかけるのは好感触です!

色の color とモード(globalCompositeOperation)の type を保持しています。

src/js/store/size.ts

import { ref } from "vue";
import { defineStore } from "pinia";

export const useSizeStore = defineStore("size", () => {
  const size = ref<number>(10);

  const sizeChange = (value: number) => {
    size.value = value;
  };

  return { size, sizeChange };
});

こちらはペンの太さ(size)を保持しています。

src/js/store/context.ts

import { ref } from "vue";
import { defineStore } from "pinia";

export const useContextStore = defineStore("context", () => {
  const context = ref<CanvasRenderingContext2D>();

  const contextSet = (value: CanvasRenderingContext2D) => {
    context.value = value;
  };

  return { context, contextSet };
});

Delete.vue でしか利用はしていないのですが、context 自体を保持しています。

pinia はオブジェクト風の書き方にも対応してるようなので(そちらが主流?)、好みで色々出来そうです。

参考:https://pinia.vuejs.org/introduction.html

ありそうなのに未対応な機能

描いた後の画面サイズの変更

canvas 要素はリサイズに弱いので、描いている途中で画面サイズを縮めたりするとペンの相対位置がズレて変になります。これを防ぐためには、キャンバスの書いた内容を逐一保存しつつ、リサイズを検知したら再貼り付けみたいな感じになりますが今回はしていません。

なので「ある程度」しかレスポンシブ対応はしていません。

Do、Undo 機能

行ったり戻ったりの機能。これも書いた内容をキューとして保存して... みたいな感じになるので上記のリサイズと同じくそれなりの機能です。

保存機能

Chrome の場合は canvas 上で右クリックで保存出来るのですが、Safari などは出来ないのであってもいいかもしれないです。

まとめ

Vue3 になってからそんなに詳しく触れてなかったので今回は Vue で書いてみました。
昔書いた時よりも JS が格段に進化しているので、面白く実装出来ました。

Vue よりは React の方が好きなのですが、style どうするの問題は Vue では起きないと思うので、その辺はいいなーと思いました。

一覧にもどる