こんにちは、デベロップメントテクノロジーセンターの岩崎です。
少し前にHTML Canvasを使ったお絵描きアプリを作ることがあったため、備忘録もかねてサンプルアプリを作ってみました。
早速ですが、今回作ったサンプルアプリは下記です。
画像ファイルをアップロードして、その画像に対してお絵描きが出来るようになっています。
 
HTML Canvas APIであるCanvasRenderingContext2Dを利用します。
(APIドキュメントはこちら)
描き込みモード(ペン、直線、短径、円)、消しゴムモードそれぞれで処理を用意し、マウスイベントをトリガーにして各処理を呼び出しています。マウス移動イベントは連続して発生するため、各処理もそのたびに連続して呼び出す形となります。
| // マウス移動イベント | 
| function mouseMove(e) { | 
| // 座標表示 | 
| document.getElementById("dispX").innerHTML = e.offsetX; | 
| document.getElementById("dispY").innerHTML = e.offsetY; | 
| if (mode == "1") { // モード:描く | 
| if (inputType == "1" || inputType == "2") { // 描き込みタイプ:ペン or 直線 | 
| pointer(e); | 
| } | 
| if (holdClick) { | 
| if (inputType == "1") { // 描き込みタイプ:ペン | 
| drawPen(e); | 
| } else if (inputType == "2") { // 描き込みタイプ:直線 | 
| drawLine(e); | 
| } else if (inputType == "3") { // 描き込みタイプ:短径 | 
| drawRect(e); | 
| } else if (inputType == "4") { // 描き込みタイプ:円 | 
| drawArc(e); | 
| } | 
| } | 
| } else if (mode == "2") { // モード:消しゴム | 
| pointer(e); | 
| if (holdClick) { | 
| drawErase(e); | 
| } | 
| } else { // モード:画像移動 | 
| if (holdClick) { | 
| imageMove(e); | 
| } | 
| } | 
| } | 
| // drawCanvasエリア描画(ペン) | 
| function drawPen(e) { | 
| drawCtx.lineWidth = brushSize; | 
| drawCtx.strokeStyle = canvasRgba; | 
| drawCtx.lineJoin = "round"; | 
| drawCtx.lineCap = "round"; | 
| drawCtx.globalCompositeOperation = 'source-over'; | 
| drawCtx.beginPath(); | 
| drawCtx.moveTo(startX, startY); // 開始座標(前回座標) | 
| drawCtx.lineTo(e.offsetX, e.offsetY); // 終了座標(現在座標) | 
| drawCtx.stroke(); // 直線を描画 | 
| drawCtx.closePath(); | 
| // 次の描画に向けて現在の座標を保持(開始・終了を同じ座標で描画すると、マウスを高速に移動したときに歯抜け状態になる) | 
| startX = e.offsetX; | 
| startY = e.offsetY; | 
| } | 
サンプルアプリでは下記4つのcanvasを重ねて表示しています。それぞれの役割を説明していきます。
①描画Canvas
②画像Canvas
③ポインタCanvas
④一時的描画Canvas
| <div class="card-body border rounded" style="background-color: silver; padding: 0rem"> | 
| <div id="canvas-area" width="100%" style="height: 80vh; position: relative; overflow: hidden;" | 
| class="canvas-wrapper text-center"> | 
| <!-- 画像Canvas --> | 
| <canvas id="imageCanvas" | 
| style="top: 0px; right: 0;bottom: 0; left: 0; margin: auto; position: absolute;"></canvas> | 
| <!-- 描画Canvas --> | 
| <canvas id="drawCanvas" | 
| style="top: 0px; right: 0;bottom: 0; left: 0; margin: auto; position: absolute;"></canvas> | 
| <!-- 一時的描画Canvas(直線、短径、円用) --> | 
| <canvas id="drawTempCanvas" | 
| style="top: 0px; right: 0;bottom: 0; left: 0; margin: auto; position: absolute;"></canvas> | 
| <!-- ポインタCanvas --> | 
| <canvas id="pointerCanvas" | 
| style="top: 0px; right: 0;bottom: 0; left: 0; margin: auto; position: absolute;"></canvas> | 
| </div> | 
| </div> | 
画面操作により色を描き込んでいくCanvasです。
上記で紹介した描き込みモード等もこのCanvasに対して描き込んでいきます。
アップロード画像を表示するCanvasです。
一つのCanvasで「画像表示&色の描き込み」を一緒に行うこともできるのですが、「アップロード画像を表示→色の描き込み→消しゴムで消す」という処理を行うと、消しゴム処理時にアップロード画像も一緒に消すことになってしまうため、別のCanvasを用意しました。
また、サンプルアプリはレスポンシブ対応も行っており、画像設定時にCanvasウインドウサイズと画像サイズのスケールを計算してアップロード画像サイズを調整しています。そのとき、重ねているすべてのCanvasに対して同じスケールを適用するようにしています。
| // imageCanvasエリア画像設定 | 
| function image(src) { | 
| var img = new Image(); | 
| img.src = src; | 
| img.onload = () => { | 
| // canvasエリアと画像のスケールを計算(縦・横 スケール値が低い方を採用) | 
| var scale = | 
| Math.min( | 
| $('#canvas-area').width() / img.naturalWidth, | 
| $('#canvas-area').height() / img.naturalHeight); | 
| // canvasエリアの高さ・幅を設定 | 
| imageCvs.width = img.width * scale; | 
| imageCvs.height = img.height * scale; | 
| drawCvs.width = imageCvs.width; | 
| drawCvs.height = imageCvs.height; | 
| drawTempCvs.width = imageCvs.width; | 
| drawTempCvs.height = imageCvs.height; | 
| pointerCvs.width = imageCvs.width; | 
| pointerCvs.height = imageCvs.height; | 
| // 画像を縮小して設定 | 
| imageCtx.drawImage(img, 0, 0, imageCvs.width, imageCvs.height); | 
| }; | 
| } | 
マウスポインタを描画するCanvasです。
ポインタを表示することで、ペンサイズの範囲はどれぐらいかすぐにわかるようにしています。
描画Canvasとは別のCanvasを用意して、マウスカーソルに合わせてCanvas上で円を描画し続ける(事前の描画は次の描画時にすぐに消す)ことでマウスポインタを表現しています。
| // pointerCanvasエリア描画 | 
| function pointer(e) { | 
| // 事前のポインタ描画を除去 | 
| pointerCtx.clearRect(0, 0, imageCvs.width, imageCvs.height) | 
| if (mode == "2") { | 
| // モード:消しゴムのときは白固定 | 
| pointerCtx.strokeStyle = "rgba(255, 255, 255, 1)"; | 
| } else { | 
| pointerCtx.strokeStyle = canvasRgba; // 事前に設定していた色 | 
| } | 
| pointerCtx.lineWidth = brushSize; // 太さ | 
| pointerCtx.lineCap = "round"; // 円 | 
| pointerCtx.beginPath(); | 
| pointerCtx.moveTo(e.offsetX, e.offsetY); | 
| pointerCtx.lineTo(e.offsetX, e.offsetY); // 開始座標と終了座標を同じ | 
| pointerCtx.stroke(); // 描画 | 
| pointerCtx.closePath(); | 
| } | 
直線・短径・円を書き込むときは、クリックホールド中にカーソルを動かしながら描画位置を調整し、ホールド解除したときに描画を確定させています。
 
            この描画位置の調整時にも色を描画させたいために一時的描画Canvasを用意しています。
一時的描画は基本的には通常描画と同じ処理であり、ホールド中か否かで描画先を「描画Canvas」or「一時的描画Canvas」どちらにするかを切り替えています。
| // drawCanvasエリア描画(直線) | 
| function drawLine(e) { | 
| // 一時的描画Canvasクリア | 
| drawTempCtx.clearRect(0, 0, imageCvs.width, imageCvs.height) | 
| if (holdClick) { | 
| // クリックホールド中は一時的描画Canvasに対して描画 | 
| targateCtx = drawTempCtx; | 
| } else { | 
| targateCtx = drawCtx; | 
| } | 
| targateCtx.lineWidth = brushSize; | 
| targateCtx.strokeStyle = canvasRgba; | 
| targateCtx.lineCap = "round"; // 先端の形状 | 
| targateCtx.globalCompositeOperation = 'source-over'; | 
| targateCtx.beginPath(); | 
| targateCtx.moveTo(startX, startY); // 開始座標(クリック開始座標) | 
| targateCtx.lineTo(e.offsetX, e.offsetY); // 終了座標(現在座標) | 
| targateCtx.stroke(); // 直線を描画 | 
| targateCtx.closePath(); | 
| } | 
ホイールによる画像の拡大縮小、クリックホールドによる画像移動の機能も組み込んでいます。この機能はCSSのtransformプロパティを使って実現しています。
 
拡大縮小のサンプルは下記です。transformの起点(transform-origin)を設定してから拡大縮小を行います。アップロード画像設定処理と同じく、すべてのCanvasに対して同じCSSの設定を行います。
| // 拡大縮小処理 | 
| function zoom() { | 
| // 拡大縮小の起点を設定 | 
| $("#imageCanvas").css({ | 
| "transform-origin": | 
| document.getElementById("dispX").innerHTML + "px " + | 
| document.getElementById("dispY").innerHTML + "px" | 
| }); | 
| $("#drawCanvas").css({ | 
| "transform-origin": | 
| document.getElementById("dispX").innerHTML + "px " + | 
| document.getElementById("dispY").innerHTML + "px" | 
| }); | 
| $("#drawTempCanvas").css({ | 
| "transform-origin": | 
| document.getElementById("dispX").innerHTML + "px " + | 
| document.getElementById("dispY").innerHTML + "px" | 
| }); | 
| $("#pointerCanvas").css({ | 
| "transform-origin": | 
| document.getElementById("dispX").innerHTML + "px " + | 
| document.getElementById("dispY").innerHTML + "px" | 
| }); | 
| // 拡大縮小 | 
| $("#imageCanvas").css({ "transform": "scale(" + zoomRario + ")" }); | 
| $("#drawCanvas").css({ "transform": "scale(" + zoomRario + ")" }); | 
| $("#drawTempCanvas").css({ "transform": "scale(" + zoomRario + ")" }); | 
| $("#pointerCanvas").css({ "transform": "scale(" + zoomRario + ")" }); | 
| } | 
画像移動のサンプルは下記です。画像移動は拡大後のみ実行可能であり、拡大時に設定した起点を変更することで画像移動を表現しています。
| // 画像移動処理 | 
| function imageMove(e) { | 
| // 対象領域の長さ | 
| var targetWidth = $("#imageCanvas").width(); | 
| var targetHeight = $("#imageCanvas").height(); | 
| // 起点位置の取得 | 
| var origin = $("#imageCanvas").css('transform-origin'); | 
| var origins = origin.replaceAll("px", "").split(" "); | 
| // 起点位置に移動量を加算 | 
| var moveX = Number(origins[0]) + (startX - e.offsetX); | 
| var moveY = Number(origins[1]) + (startY - e.offsetY); | 
| // 起点位置を対象範囲内に設定 | 
| if (moveX < 0) { | 
| moveX = 0; | 
| } else if (moveX > targetWidth) { | 
| moveX = targetWidth; | 
| } | 
| if (moveY < 0) { | 
| moveY = 0; | 
| } else if (moveY > targetHeight) { | 
| moveY = targetHeight; | 
| } | 
| // 起点位置を変更 | 
| $("#imageCanvas").css({ "transform-origin": moveX + "px " + moveY + "px" }); | 
| $("#drawCanvas").css({ "transform-origin": moveX + "px " + moveY + "px" }); | 
| $("#drawTempCanvas").css({ "transform-origin": moveX + "px " + moveY + "px" }); | 
| $("#pointerCanvas").css({ "transform-origin": moveX + "px " + moveY + "px" }); | 
| } | 
いかがでしたでしょうか。
私はあまりフロントエンドは得意ではないのですが、今回のようなすぐ目に見えるアプリを作るのは楽しいですね。
今後も機会があればまた試していきたいと思います。
 



