画像のおえかき機能の設置方法(fabric.js使用)

更新

画像のおえかき機能の設置方法(fabric.js使用)タイトル画像

web謎を作ろうと思ったときにユーザビリティを考えておえかき機能を設置したい人は少なくないと思います。

この記事では、fabric.jsを使用しておえかき機能を設置する方法について説明していきます。

1ページに複数のcanvasの設置が可能なので、1ステップに複数枚の謎が存在するweb謎にも使用できます!

(このプログラムはおんせんさんの作成したpaint.jsを改良したものとなります。この場を借りてお礼を申し上げます。)

サンプル

まずはサンプルです。

サイズの異なる画像を背景にできます。

キャンバス1

ペンの種類はcanvas間で連動します。

キャンバス2

このように1ページに複数のcanvasを設置し、独立しておえかきができます。

実装方法

htmlファイル

最低限必要なソースは以下です。

<head>
  <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js"></script><!-- jquery -->
  <script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/2.4.4/fabric.min.js"></script><!-- fabric.js -->
  <script src="./paint.js"></script><!-- paint.js -->
  <link href="./paint.css" rel="stylesheet" type="text/css"><!-- paint.css -->
</head>

<body>
  <div class="canvas-wrapper">
    <canvas data-name="paintSample1" data-url="./img/sample.png"></canvas>
  </div>
</body>

大枠として必要なjqueryとfabric.jsを順序通りに読み込んだ上で、今回のcanvas生成用のpaint.jsとpaint.cssを読むようにします。

jqueryとfabric.jsは今回はminのCDN(外部読み込み)を使用していますが、自分のサイト内に置いているもので問題ありません。

html内での注意は、あるとすれば「data-nameはcanvas間で被らないようにする」くらいでしょうか?

data-nameを用いてcanvasの描画データを保存しているので、data-nameが被っていると、いきなり関係ないcanvasに描画データが移ることがあります。

paint.jsとpaint.cssは下記をコピペして適切な場所に作成・保存してください。

また、アイコン画像も別で用意する必要があります。

paint.js

jsは、基本的にそのままコピペで使用できると思います。

ペンの色や太さを変えたい方は、該当部を変更してください。

let nowCvsNum = 0;
let cvsArr = [];
let isRedoing = false;
let penType = "pencil";
if (typeof setPenType !== 'undefined'){
  console.log("setPenType is not null");
  penType = setPenType;
}else{
  console.log("setPenType is null");
}

document.addEventListener('DOMContentLoaded', function() {
  let cvsNum = $('.canvas-wrapper').length;
  console.log("cvsNum:",cvsNum)
  // 各Canvasの設定
  for (let i=0; i<cvsNum; i++) {
    makeCanvas(i);
  };
  if(penType == "wPen"){
    setwPen();
  }else if(penType == "marker"){
    setMarker();
  }else{
    setPencil();
  }
});

function makeCanvas(i) {
  // Canvas要素を取得
  let cvsElem = $('.canvas-wrapper:eq('+i+') canvas')[0];
  cvsElem.style.border = "solid 0.05rem #cccccc";
  let cvs = new fabric.Canvas(cvsElem, {
    freeDrawingCursor: 'none',
    isDrawingMode: true
  });
  cvs.loadFromJSON(sessionStorage.getItem($(cvsElem).data('name')));
  // 背景画像を設定
  let bgImgUrl = cvsElem.dataset.url;
  setCvsBgImg(cvs, bgImgUrl, i);
  let fcDiv = $('<div class="function"><div class="icon undo" title="戻る"></div><div class="icon redo" title="進む"></div><div class="icon pencil" title="鉛筆"></div><div class="icon wpen" title="白ペン"></div><div class="icon marker" title="赤太ペン"></div><div class="icon edit" title="移動"></div><div class="icon reset" title="全消去"></div><div class="icon download" title="ダウンロード"></div></div>');
  $('.canvas-wrapper:eq('+i+')').before(fcDiv);
  if(i == 0){//1番目のcanvas直前にcvsCursorを生成する。
    let cvsCursor = $('<div id="cvsCursor" class="cc_pencil nodisp"></div>');
  $('.canvas-wrapper:eq(0)').before(cvsCursor);
  }
  // 作成したCanvasを配列に格納する
  cvsArr.push(cvs);

  //undo, save
  cvs.on('object:added', function(){
  if(!isRedoing) h = [];
  isRedoing = false;
  sessionStorage.setItem($(cvsElem).data('name'), JSON.stringify(cvs));
  });
}

function setCvsBgImg(cvs, bgImgUrl, i) {
  fabric.Image.fromURL(bgImgUrl, function(img){
    // 画像の縦横比を取得する
    let imgAspectRatio = img.width / img.height;
    // Canvasのサイズを設定する
    let cvsWidth = $('.canvas-wrapper').eq(i).outerWidth();
    let cvsHeight = cvsWidth / imgAspectRatio;
    cvs.setWidth(cvsWidth);
    cvs.setHeight(cvsHeight);
    // 画像を拡大縮小する
    img.scaleToWidth(cvsWidth);
    cvs.setBackgroundImage(img);
  });
}

$(document).on('click', '.pencil', function() {
  nowCvsNum = $('.pencil').index(this);
  setPencil(cvsArr[nowCvsNum]);
  // $('#cvsCursor').removeClass('cc_wPen cc_marker');
  // $('#cvsCursor').addClass('cc_pencil');
});
$(document).on('click', '.wpen', function() {
  nowCvsNum = $('.wpen').index(this);
  setwPen(cvsArr[nowCvsNum]);
  // $('#cvsCursor').removeClass('cc_pencil cc_marker');
  // $('#cvsCursor').addClass('cc_wPen');
});
$(document).on('click', '.marker', function() {
  nowCvsNum = $('.marker').index(this);
  setMarker(cvsArr[nowCvsNum]);
  // $('#cvsCursor').removeClass('cc_pencil cc_wPen');
  // $('#cvsCursor').addClass('cc_marker');
});
$(document).on('click', '.edit', function() {
  nowCvsNum = $('.edit').index(this);
  edit(cvsArr[nowCvsNum]);
  $('#cvsCursor').removeClass('cc_pencil cc_wpen cc_marker');
});
$(document).on('click', '.reset', function() {
  nowCvsNum = $('.reset').index(this);
  reset(cvsArr[nowCvsNum]);
});
$(document).on('click', '.download', function() {
  nowCvsNum = $('.download').index(this);
  download(cvsArr[nowCvsNum]);
});
$(document).on('click', '.undo', function() {
  nowCvsNum = $('.undo').index(this);
  undo(cvsArr[nowCvsNum]);
});
$(document).on('click', '.redo', function() {
  nowCvsNum = $('.redo').index(this);
  redo(cvsArr[nowCvsNum]);
});

function discardSelection() {
  for(let i = 0; i < cvsArr.length; i++) {
    let cvs = cvsArr[i];
    cvs.discardActiveObject();
    cvs.renderAll();
  }
}

function setPencil() {
  $('.pencil').addClass('selected');
  $('#cvsCursor').removeClass('cc_wPen cc_marker');
  $('#cvsCursor').addClass('cc_pencil');
  $('.wpen, .marker, .edit').removeClass('selected');
  for(let i = 0; i < cvsArr.length; i++) {
    let cvs = cvsArr[i];
    cvs.isDrawingMode = true;
    cvs.freeDrawingBrush.width = 2;
    cvs.freeDrawingBrush.color = 'rgba(20,20,20,0.85)';
  }
  discardSelection();
}
function setwPen() {
  $('.wpen').addClass('selected');
  $('#cvsCursor').removeClass('cc_pencil cc_marker');
  $('#cvsCursor').addClass('cc_wPen');
  $('.pencil, .marker, .edit').removeClass('selected');
  for(let i = 0; i < cvsArr.length; i++) {
    let cvs = cvsArr[i];
    cvs.isDrawingMode = true;
    cvs.freeDrawingBrush.width = 3;
    cvs.freeDrawingBrush.color = 'rgba(255,255,200,0.85)';
  }
  discardSelection();
}
function setMarker() {
  $('.marker').addClass('selected');
  $('#cvsCursor').removeClass('cc_pencil cc_wPen');
  $('#cvsCursor').addClass('cc_marker');
  $('.pencil, .wpen, .edit').removeClass('selected');
  for(let i = 0; i < cvsArr.length; i++) {
    let cvs = cvsArr[i];
    cvs.isDrawingMode = true;
    cvs.freeDrawingBrush.width = 5;
    cvs.freeDrawingBrush.color = 'rgba(255,0,30,0.5)';
  }
  discardSelection();
}
function edit() {
  $('.edit').addClass('selected');
  $('.pencil, .wpen, .marker').removeClass('selected');
  for(let i = 0; i < cvsArr.length; i++) {
    let cvs = cvsArr[i];
    cvs.isDrawingMode = false;
  }
}
function reset(cvs) {
  cvs.clear();
  let i = cvsArr.indexOf(cvs); // 現在のCanvasが配列の何番目にあるかを取得
  let bgImgUrl = $('.canvas-wrapper:eq('+i+') canvas').data('url'); // 対象のCanvasの背景画像のURLを取得
  setCvsBgImg(cvs, bgImgUrl, i); // 背景画像を再設定
}
function download(cvs){
  let a = document.createElement('a');
  a.href = cvs.toDataURL('image/png');
  a.download = 'canvas.png';
  a.click();
}
function undo(cvs){
  if(cvs._objects.length>0){
    h.push(cvs._objects.pop());
    cvs.requestRenderAll();
  }
  discardSelection();
}
function redo(cvs){
  if(h.length > 0){
    isRedoing = true;
    cvs.add(h.pop());
  }
  discardSelection();
}

//canvas内のマウスの挙動

$(document).on("mousemove", function(e) {
  const $cursor = $("#cvsCursor");
  $cursor.css({
    transform: 'translate('+e.clientX+'px,'+e.clientY+'px)'
  });
});

$(function() {
  const $cursor = $("#cvsCursor");
  $('canvas').hover(
    // マウスポインターが画像に乗った時の動作
    function(e) {
      $cursor.removeClass('nodisp');
    },
    // マウスポインターが画像から外れた時の動作
    function(e) {
      $cursor.addClass('nodisp');
    }
  );
});

paint.css

cssでは、下記コードからメニュー用アイコン画像の部分の記述をサイトに合わせて変更してご利用ください。

.image {
  max-width: 400px;
  height: auto;
  margin-bottom: 3.0rem;
}
.image2 {
  max-width: 600px;
  height: auto;
  margin-bottom: 3.0rem;
}

.function {
  font-size: 0;
  margin-top: 5.0rem;
  width: 95%;
}
.function .icon {
  display: inline-block;
  margin: 0.4rem;
  width: 4.0rem;
  height: 4.0rem;
  background-size: 4.0rem!important;
  border: solid 0.05rem #cccccc;
  border-radius: 10%;
  position: relative;
  cursor: pointer;
}
.function .selected::after {
  content: "";
  position: absolute;
  width: 4.0rem;
  height: 4.0rem;
  left: 0;
  box-sizing: border-box;
  box-shadow: 0 0 0.3rem #1abc9c;
  border: 2px solid #1abc9c;
}
/* ↓このあたりの画像や画像パスについてはご自身で用意・設定してください。 */
.function .undo {
  background: url('/common/paintsystem/img/undo.png') no-repeat top left;
}
.function .redo {
  background: url('/common/paintsystem/img/redo.png') no-repeat top left;
}
.function .pencil {
  background: url('/common/paintsystem/img/bpen.png') no-repeat top left;
}
.function .wpen {
  background: url('/common/paintsystem/img/wpen.png') no-repeat top left;
}
.function .marker {
  background: url('/common/paintsystem/img/rpen.png') no-repeat top left;
}
.function .edit {
  background: url('/common/paintsystem/img/move.png') no-repeat top left;
}
.function .reset {
  background: url('/common/paintsystem/img/reset.png') no-repeat top left;
}
.function .download {
  background: url('/common/paintsystem/img/dl.png') no-repeat top left;
}
@media screen and (max-width: 400px) {
  .function .icon {
    display: inline-block;
    margin: 2px;
    width: 3.2rem;
    height: 3.2rem;
    background-size: 3.2rem!important;
  }
  .function .selected::after {
    content: "";
    position: absolute;
    width: 3.2rem;
    height: 3.2rem;
    left: 0;
    box-sizing: border-box;
    box-shadow: 0 0 3px #1abc9c;
    border: 1px solid #1abc9c;
  }
}
.function .flash {
  opacity: 1.0;
  animation: flash 0.7s;
}
@keyframes flash {
    0% { opacity: 0.2; }
  100% { opacity: 1.0; }
}
.fabric-wrapper {
  width: 100%;
}
.canvas-container {
  margin: 0;
}

canvas {
  cursor: none;
}
#cvsCursor {
  transform: translate(0, 0);
  pointer-events: none;
  position: fixed;
  top: var(--cursor-offset);
  left: var(--cursor-offset);
  width: var(--cursor-size);
  height: var(--cursor-size);
  border-radius: 50%;
  z-index: 999;
}
#cvsCursor.cc_pencil {
  --cursor-offset: -0.2rem;
  --cursor-size: 0.4rem;
  background: rgba(0,0,0,0.9);
  border: none;
}
#cvsCursor.cc_wPen {
  --cursor-offset: -0.3rem;
  --cursor-size: 0.6rem;
  background: rgba(255,255,200,0.8);
  border: solid 0.1rem #333300;
}
#cvsCursor.cc_marker {
  --cursor-offset: -0.4rem;
  --cursor-size: 0.8rem;
  background: rgba(255,100,100,0.7);
  border: solid 0.1rem #660000;
}

アイコン用画像

また、メニュー用アイコン画像を自身で用意して設定していただく必要があります。

下記に弊サイトで使用しているものを置いておきますので、必要に応じてダウンロードしてご自由にお使いください。

1手戻す用アイコン 1手進む用アイコン 鉛筆用アイコン 白ペン用アイコン 赤ペン用アイコン 移動用アイコン リセット用アイコン 画像ダウンロード用アイコン

今までのバージョンからの改善点

弊サイトでは元々fabric.jsを用いたpaint.jsのプログラム(おんせんさんver.)を使用していましたが、今回のバージョンでは以下の改善点があります。

  • 1ページ中に複数のcanvasを作成できる
  • マウスポインタを十字カーソルからペンに応じたものに変更
  • メニューをjsで生成するように変更
1ページ中に複数のcanvasを作成できる

この点が今回の改善のメインです。

今まで、1ステップで複数枚ある場合でも1枚までしかcanvasを用意できませんでしたが、この改修によりすべての画像におえかき機能を用意できるようになりました。

検索しても全然出てこなかったので、多分このサイトが日本では初です。

そのような需要がそもそも無かったとも言います。

マウスポインタを十字カーソルからペンに応じたものに変更

最近はスマホから挑戦されている方が多いので需要は怪しいですが、十字カーソルよりは使用しやすいと思ったので、下記サイトを参考に一緒に導入しました。

お絵かきアプリにブラシサイズと色が反映されるドットカーソルを追加した - make it easy

メニューをjsで生成するように変更

今まではメニューについてhtmlにベタ書きをしていたのですが、ソースが長くなるのもあったので改善したい点の1つでした。

.canvas-wrapperクラスの直前にjsでメニューを生成するようにしたことで、html側の記述がスッキリしました。

よくある質問

上手く動かない(canvasが表示されない)んだけど…。

jsファイルの読み込みが正しく行われていない可能性があります。


以下の点を確認してください。

  • jquey, fabric.js, paint.jsが正しくこの順で呼ばれているかを確認してください。
メニューのアイコンが表示されないんだけど…。

メニューアイコン画像はご自身で用意して設置していただく必要があります。


paint.cssの40行目以降のパスを、設置していただいた画像のパスに変更してください。

画像の用意が面倒な場合は弊サイトで利用している画像をご用意していますのでご活用ください。

ページをリロードすると消したはずの線が復活するんだけど…。

仕様です。


「戻る」と「全消去」の処理をキャッシュに保存することができないため、ページをリロードした場合に消した線が復活するようになっています。

「全消去」や「戻る」の処理の後に少しでも描画を行えばキャッシュが更新されますので、どうしても必要な場合には適当に端の方に描画を行うことで対処してください。

まとめ

  • fabric.jsを用いて複数canvasを生成し、それぞれにおえかき機能を持たせることに成功しました!
  • 良かったら使ってみてね!

ちなみに年越し謎さんのサイトだと、ペンの色や太さをユーザー側で変えられたり消しゴム機能があったりします。

個人的にはそこまでスペースを取っても使用する人は殆どいないため、ペンに関しては現行の3種類くらいで良いのではと考えています。

しかし消しゴム機能は便利そうだなと思う部分があるので、余力があるときに改良します。

↓↓↓この記事をシェア↓↓↓