福岡から世界中の"むずかしい"を簡単にする株式会社diffeasyCTOの西@_takeshi_24です。
この記事はアドベントカレンダー「diffeasyCTO西の24(にし)日連続投稿チャレンジ Advent Calendar 2019」の4日目の記事です。
#Nuxt.jsとFabric.jsでCanvasお絵かき
ある開発プロジェクトで、Webアプリケーション内で画像にお絵かきして保存する処理が必要になりました。
利用する端末はiPadで、Safariブラウザを使って、タッチペンで書き込みます。
タッチペンでの記入のほか、画像には細かい文字もあり、ピンチイン・ピンチアウトによるズーム機能も必要です。
前提として、フロントエンドはNuxt.js & TypeScriptで開発しています。
これを素のCanvasで一から開発していくとかなり大変なので、今回はFabric.jsを利用することにしました。
##Fabric.jsで困ったこと
- Fabric.jsでCanvasに配置した画像の拡大縮小はできるけど、iPadのピンチイン・ピンチアウトで、Canvas全体のズームイン・ズームアウトができない。
- ブラウザの画面自体をズームしても良いけど、固定のメニューは拡大縮小したくない。
- ブラウザの拡大の逆倍率を固定メニューにかけて大きさを変えずに表示することはできるけど、ピンチイン状態で、スクロールすると固定メニューが画面外に動いて隠れてしまう。
色々試してみて結果うまくいったのですが、
- 画面リロード時はうまく動作するけど、画面初回遷移時はうまく動作しなくなる。
- 画面遷移して戻ってくるとうまく動作しなくなる。
などの問題も発生しました。
ローカル環境のNuxt.jsをiPad実機で動作確認する
何はともあれ、ピンチイン・ピンチアウトなど、iPadでの動きを確認するため、iPadの実機からローカル環境のNuxt.jsアプリケーションに接続が必要です。
ChromeディベロッパーツールでのiPadデバイスシミュレータや、XcodeのiOSシミュレータなどありますが、どれもiPadでの処理を忠実に再現できません。
iPad実機自体は、同じWifi内にMacOSがあれば、MacOSのIPアドレスをブラウザに入力して接続可能です。
IPアドレスで接続するため、Nuxt.jsをlocalhostではなく、IPアドレスで起動する必要があります。
ここでは、ローカルのIPは「192.168.0.132」とします。
package.jsonに以下の内容を追加します。
"config": {
"nuxt": {
"host": "192.168.0.132",
"port": "3000"
}
},
さらに今回、開発でAuth0を利用していたため、localhost以外のIPアドレスでアクセスするには、http通信ではセキュリティエラーが発生し、繋がりませんでした。
auth0-spa-js must run on a secure origin
SSL通信が必要になります。
以降の対応は、Auth0など利用していなければ不要です。
この辺りを参考にオレオレ証明書を作成し、ssl証明書と、認証キーを読み込みます。
import fs from 'fs'
・・・
server: {
https: {
key: fs.readFileSync(path.resolve(__dirname, 'ssl/server.key')),
cert: fs.readFileSync(path.resolve(__dirname, 'ssl/server.crt'))
}
},
これで、iPadからhttps://192.168.9.132:3000 にアクセスすることで、ローカルで開発中のNuxt.jsアプリケーションに接続できます。
さらにiPadをUSBでつないで、Safariを起動し、メニューの「開発」を開くと、iPadのSafariで実行中のWebアプリのデバッグも可能です。
以上で、開発の準備は完了です。
##Nuxt.jsとFabric.jsでCanvasお絵かき完成!
それでは、本題。
Nuxt.jsとFabric.jsで作ったCanvasのお絵かき画面が、iPadのタッチ操作でうまく動作しない問題、色々試行錯誤しましたが、以下の方法で解決しましたので、共有します!
まず、画面のタッチ操作をFabric.jsのCanvasが全部吸収してしまうので、お絵かきモードと、タッチ操作モードを分けて、タッチ操作モードの時は、Canvasの上にdivを被せて、Canvasのタッチイベントが発生しないようにしました。
お絵かきモードの時は、上にかぶせたdivを非表示にします。
class="the-canvas-editor__cover"
のdivが、上で述べたcanvasに被せるdivです。
<template>
<div class="the-canvas-editor">
<!-- Canvasに被せるdiv -->
<div v-if="!isEditing" class="the-canvas-editor__cover"></div>
<!-- 画面上部固定メニュー -->
<the-canvas-editor-controller
v-bind="canvasEditorControllerProps"
@cancel="cancel()"
@done="saveCanvas()"
/>
<!-- Fabric.jsのCanvas -->
<canvas
id="canvas"
class="the-canvas-editor__canvas"
:width="canvasWidth"
:height="canvasHeight"
/>
<!-- 画面下部固定メニュー -->
<div class="the-canvas-editor__toolbar">
<div class="the-canvas-editor__toolbar__wrap">
<the-cnavas-tool-bar
@pen="startPen"
@color="changeColor"
@marker="startMarker"
@eraser="startEraser"
@select="startSelectObjects"
/>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.the-canvas-editor {
position: relative;
&__cover {
position: absolute;
z-index: 1;
width: 100%;
height: 100%;
}
&__controller {
position: absolute;
z-index: 2;
width: 100%;
}
&__toolbar {
position: fixed;
z-index: 2;
right: 0;
bottom: 0;
left: 0;
padding-right: 20px;
padding-left: 20px;
}
}
プログラムの主な箇所だけ抜粋しています。
<script lang="ts">
type DataType = {
canvas: any;
isTouching: boolean;
isEditing: boolean;
pointX: number;
pointY: number;
};
export default Vue.extend({
name: 'TheCanvasEditor',
data: (): DataType => ({
canvas: null,
isTouching: false,
isEditing: false,
pointX: 0,
pointY: 0,
}),
export default Vue.extend({
async mounted() {
this.canvas = new fabric.Canvas('canvas');
this.$nextTick(function() {
// レンダリング完了後にイベントリスナーを登録
window.addEventListener(
'touchmove',
this.touchMoveEvent,
this.passiveMode,
);
window.addEventListener('touchend', this.touchEndEvent, this.passiveMode);
window.addEventListener(
'orientationchange',
this.orientationChange,
this.passiveMode,
);
});
},
beforeDestroy() {
window.removeEventListener('touchmove', this.touchMoveEvent);
window.removeEventListener('touchend', this.touchEndEvent);
window.removeEventListener('resize', this.orientationChange);
},
methods: {
touchMoveEvent(e) {
// 画面タッチ移動
let d0: number = 1;
let d1: number = 1;
e.preventDefault();
if (!this.isEditing && e.touches.length == 1) {
// スワイプスクロール
if (!this.isTouching) {
this.isTouching = true;
this.pointX = e.touches[0].screenX;
this.pointY = e.touches[0].screenY;
} else {
const fabricPoint: any = new fabric.Point(
e.touches[0].screenX - this.pointX,
e.touches[0].screenY - this.pointY,
);
this.pointX = e.touches[0].screenX;
this.pointY = e.touches[0].screenY;
this.canvas.relativePan(fabricPoint);
}
} else if (!this.isEditing && e.touches.length == 2) {
// ピンチイン・ピンチアウト
if (!this.isTouching) {
this.isTouching = true;
d0 = Math.sqrt(
Math.pow(e.touches[1].screenX - e.touches[0].screenX, 2) +
Math.pow(e.touches[1].screenY - e.touches[0].screenY, 2),
);
} else {
d1 = Math.sqrt(
Math.pow(e.touches[1].screenX - e.touches[0].screenX, 2) +
Math.pow(e.touches[1].screenY - e.touches[0].screenY, 2),
);
const zoomer = d1 / d0 < 100 ? 1 : d1 / d0 / 100;
const fabricPoint: any = new fabric.Point(
(e.touches[0].screenX + e.touches[1].screenX) / 2,
(e.touches[0].screenY + e.touches[1].screenY) / 2,
);
if (zoomer === 1) {
this.canvas.setZoom(1);
const fabricPoint: any = new fabric.Point(0, 0);
this.canvas.absolutePan(fabricPoint);
} else {
this.canvas.zoomToPoint(fabricPoint, zoomer);
}
}
}
},
touchStartEvent(e) {
// 画面タッチ開始
if (!this.isEditing && e.touches.length == 1) {
e.preventDefault();
this.canvas.setZoom(1);
const fabricPoint: any = new fabric.Point(0, 0);
this.canvas.absolutePan(fabricPoint);
}
return;
},
touchEndEvent(e) {
// 画面タッチ終了
this.isTouching = false;
},
startEditing() {
// 編集モード開始
this.isEditing = true;
},
finishEditing() {
// 編集モード終了
this.isEditing = false;
},
},
});
####プログラムのポイント解説
ポイントのみ解説します。
#####canvasに被せるdivの切り替え
- this.isEditingを切り替えることで、canvasに被せるdivの表示非表示を切り替えています。
#####イベントリスナーの登録削除
- mountedの
this.$nextTick(function() {・・・});
の中でイベントリスナーを登録しています。mountedは、全ての子コンポーネントもマウントされていることを保証しません。
$nextTickを利用することで、ビュー全体がレンダリングされるまで待つことができます。 - beforeDestroyで、イベントリスナーを破棄しています。イベントリスナーを破棄しないと、Nuxt.jsで画面遷移してもイベントリスナーが残ったままになります。
beforeDestroyはインスタンスが破棄される直前に呼ばれます。
#####スワイプでスクロール
- touchMoveEventで
e.touches.length == 1
の場合がスワイプ処理。 -
this.canvas.relativePan(fabricPoint);
で指を移動させた分の相対位置でcanvasを移動します。
#####ピンチイン・ピンチアウト
- touchMoveEventで
e.touches.length == 2
の場合がピンチイン・ピンチアウト処理。 - 指と指の幅から拡大率を計算します。
d1 = Math.sqrt(
Math.pow(e.touches[1].screenX - e.touches[0].screenX, 2) +
Math.pow(e.touches[1].screenY - e.touches[0].screenY, 2),
);
const zoomer = d1 / d0 < 100 ? 1 : d1 / d0 / 100;
- 指と指の中間点から、ピンチイン・ピンチアウトするポイントを計算します。
const fabricPoint: any = new fabric.Point(
(e.touches[0].screenX + e.touches[1].screenX) / 2,
(e.touches[0].screenY + e.touches[1].screenY) / 2,
);
-
this.canvas.zoomToPoint(fabricPoint, zoomer);
で、ピンチイン・ピンチアウトします。
##まとめ
なんとかFabric.jsを使ったCanvasのピンチイン・ピンチアウトはできるようになりましたが、結構無理やりな部分もあるので、もっとスマートなやり方をご存知の方は、ぜひ教えてください!
引き続き、アドベントカレンダー「diffeasyCTO西の24(にし)日連続投稿チャレンジ Advent Calendar 2019」をよろしくお願いします!
是非Qiitaアカウントかtwitterをフォローしていただき、ツッコミやいいね!お願いします!
#advent_24のハッシュタグでフィードバックいただけると嬉しいです!