2017/09/19

[HTML5]Canvasで画像をズームイン・アウト、ドラッグで移動させる方法

Twitterのアイコン・ヘッダー画像のアップロード機能のように、画像を読み込み、ブラウザ上で簡単な編集・トリミング加工し、画像ファイルとして出力したい。

いろいろ調べてみると、HTML5のCanvasを使えばできることがわかった。
ということで、Canvasで画像のズームイン・アウト(拡大縮小)、トリミング(切り取り)する方法をまとめる。


↓こんな感じのを実装する。


※ ちなみにTwitterのアップロード機能はCanvasは使われておらず、imgタグで頑張っているみたい

Step1: 画像をCanvasに描画する


まずは画像をCanvasに描画する。
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
document.body.appendChild(canvas);

const img = new Image();
img.src = '画像のURLなど';

img.onload = () => {
  // Canvasを画像のサイズに合わせる
  canvas.height = img.height;
  canvas.width  = img.width;

  // Canvasに描画する
  ctx.drawImage(img, 0, 0);
};

img.onerror = () => {
  console.log('画像の読み込み失敗');
};

getContext('2d')で2Dレンダリングコンテキストを取得する。
このコンテキストを使うことで、Canvasに画像や図形などを描画することができる。

img.onload内で、画像(img)をctx.drawImage()を使ってCanvasに描画している。

※ img.srcの読み込みは非同期なのでonload(読み込み後)に描画している



Step2: 画像をズームイン・ズームアウトする


<input type="range">でスライダーを表示し、そのスライダーにあわせて画像を拡大縮小する。

<div id="image">
</div>

<div class="slider">
  <span>縮小</span>
  <input id="zoom-slider" type="range">
  <span>拡大</span>
</div>
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
document.getElementById('image').appendChild(canvas);

const img = new Image();
img.src = 'https://ja.wikipedia.org/static/images/project-logos/jawiki.png';

img.onload = () => {
  // Canvasを画像のサイズに合わせる
  canvas.height = img.height;
  canvas.width  = img.width;

  // Canvasに描画する
  ctx.drawImage(img, 0, 0);
};

img.onerror = () => {
  console.log('画像の読み込み失敗');
};


const slider = document.getElementById('zoom-slider');
slider.value = 1;
// 倍率の最小・最大値
slider.min = 0.01;
slider.max = 2;
// 粒度
slider.step = 'any';

// スライダーが動いたら拡大・縮小して再描画する
slider.addEventListener('input', e => {
  // 一旦クリア 
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  // 倍率変更
  const scale = e.target.value;
  ctx.scale(scale, scale);
  // 再描画
  ctx.drawImage(img, 0, 0);
  // 変換マトリクスを元に戻す
  ctx.scale(1 / scale, 1 / scale);
});


描画するところまで、Step1と同じ。

まずはスライダーの設定から。
  • value: 初期値の倍率
  • min / max: スライダーの最小・最大値。minを0にすると画像が消えてしまうので0.01を設定
  • step: スライダーの粒度(anyでなめらかに変更できる)


続いて、スライダーのinputイベントをトリガーにして画像の拡大縮小を行う。

ctx.clearRect()で一旦Canvasをクリアしてから再描画する。クリアしないと拡大縮小過程の画像がすべて残ってしまうので注意。

ctx.scale()で画像の拡大縮小ができる。基準を1として2なら2倍、0.5なら1/2倍といった感じだ。

scaleの設定が終わったら、ctx.drawImage()で変更後の画像を再描画する。

最後にctx.scale(1 / scale, 1/ scale)でスケールを基準値に戻している。
例えば、2倍にしてから1倍にしたいとき、変換マトリクスを戻さないと「2倍にした画像が基準」になるので、次にctx.scale(1, 1)に変換しようとしてもサイズが変わらないためだ。



Step3: 画像をドラッグで移動させる


ドラッグで画像の位置を調整する。
<div id="image">
</div>
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
document.getElementById('image').appendChild(canvas);

const img = new Image();
img.src = 'https://ja.wikipedia.org/static/images/project-logos/jawiki.png';
img.crossOrigin = 'anonymous';

img.onload = () => {
  // Canvasを画像のサイズに合わせる
  canvas.height = img.height;
  canvas.width  = img.width;

  // Canvasに描画する
  ctx.drawImage(img, 0, 0);
};

img.onerror = () => {
  console.log('画像の読み込み失敗');
};

/** ドラッグで移動 */
// ドラッグ状態かどうか
let isDragging = false;
// ドラッグ開始位置
let start = {
  x: 0,
  y: 0
};
// ドラッグ中の位置
let diff = {
  x: 0,
  y: 0
};
// ドラッグ終了後の位置
let end = {
  x: 0,
  y: 0
}
const redraw = () => {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.drawImage(img, diff.x, diff.y)
};
canvas.addEventListener('mousedown', event => {
  isDragging = true;
  start.x = event.clientX;
  start.y = event.clientY;
});
canvas.addEventListener('mousemove', event => {
  if (isDragging) {
    diff.x = (event.clientX - start.x) + end.x;
    diff.y = (event.clientY - start.y) + end.y;
    redraw();
  }
});
canvas.addEventListener('mouseup', event => {
  isDragging = false;
  end.x = diff.x;
  end.y = diff.y;
});
mousedown, mousemove, mouseupのイベントを使ってドラッグの処理を行う。

mousedownでドラッグ開始。
ドラッグ状態であることを示すフラグをたて、マウスダウン(クリック)されたところの座標(start.x, start.y)を保持する。

mousemoveでドラッグ中。
(現在のマウスの位置 - 開始位置) + ドラッグ終了時の位置で移動分の値を取得。
最後にドラッグ終了時の位置を足しているのは、前回のドラッグで動かされた分を考慮するためだ。
そして再描画を行う。

mouseupでドラッグ終了。
ドラッグ終了を示すフラグをたて、次回ドラッグ時に使うために最終描画位置を保存する。



Step4: 倍率を保持したまま、画像を移動させる


最後のステップとして、倍率を保持したままドラッグで画像を移動させる。
ちょっとした処理を加えないと、ドラッグで移動させようとするとサイズが戻ってしまうためだ。

このステップでは、前の3ステップをすべて組み合わせ値を保持するためclassベースで実装していく。

<div id="image">
</div>
class ImageEditor {
  /**
   * Canvas操作
   * @param {Object} opt 
   * @param {string} opt.imageSrc イメージのURL
   * @param {string} opt.canvasId canvasタグのid(デフォルト: image-for-edit)
   * @param {Number} opt.canvasSize canvasのサイズ(デフォルト: 128px)
   * @param {Number} opt.scaleStep 拡大縮小の倍率(デフォルト: 0.25)
   */
  constructor(opt = {}) {
    this.src = opt.imageSrc || 'https://ja.wikipedia.org/static/images/project-logos/jawiki.png';
    this.id = opt.canvasId || 'image-for-edit';
    this.size = opt.canvasSize || 128;
    this.scaleStep = opt.scaleStep || 0.25;

    this.scale = 1;
    this.dragInfo = {
      isDragging: false,
      startX: 0,
      startY: 0,
      diffX: 0,
      diffY: 0,
      canvasX: 0,
      canvasY: 0
    };
  }

  /**
   * canvasを挿入する
   * @param {HTMLElement} el canvasを挿入する親要素
   * @return {void}
   */
  insertTo(el) {
    const container = document.createElement('div');
    el.appendChild(container);

    // slider
    const zoomSlider = document.createElement('input');
    zoomSlider.type = 'range';
    zoomSlider.min = 0.01;
    zoomSlider.max = 2;
    zoomSlider.value = 1;
    zoomSlider.step = 'any';
    zoomSlider.addEventListener('input', this.zoom.bind(this));
    container.appendChild(zoomSlider);

    // canvas
    this.canvas = document.createElement('canvas');
    this.ctx = this.canvas.getContext('2d');

    this.canvas.id = this.id;
    this.canvas.width = this.canvas.height = this.size;

    this.img = new Image();
    this.img.crossOrigin = 'anonymous';   // 「Failed to execute 'toDataURL' on 'HTMLCanvasElement': Tainted canvases may not be exported.」というエラーになるため
    this.img.src = this.src;
    this.img.onload = () => {
      this.ctx.drawImage(this.img, 0, 0);
    };
    this.img.onerror = e => {
      [...el.children].forEach(a => a.remove());
      alert('画像読み込み失敗');
    };

    // mouse event
    this.canvas.addEventListener('mousedown', this.dragStart.bind(this));
    this.canvas.addEventListener('mousemove', this.drag.bind(this));
    this.canvas.addEventListener('mouseup', this.dragEnd.bind(this));

    el.appendChild(this.canvas);
  }

  /**
   * 再描画する
   * @private
   * @return {void}
   */
  _redraw() {
    // canvasをクリア
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
    // リサイズ
    this.ctx.scale(this.scale, this.scale);
    // 再描画
    this.ctx.drawImage(this.img, this.dragInfo.diffX, this.dragInfo.diffY);
    // 変形マトリクスを元に戻す
    this.ctx.scale(1 / this.scale, 1 / this.scale);
  }

  /**
   * 拡大/縮小する
   * @param {Event} event イベント
   * @return {void}
   */
  zoom(event) {
    this.scale = event.target.value;
    this._redraw();
  }

  /**
   * 拡大(ズームイン)する
   * @return {void}
   */
  zoomIn() {
    this.scale += this.scaleStep;
    this._redraw();
  }
  /**
   * 縮小(ズームアウト)する
   * @return {void}
   */
  zoomOut() {
    this.scale -= this.scaleStep;
    this._redraw();
  }

  /**
   * ドラッグ開始
   * @param {MouseEvent} event マウスイベント
   * @return {void}
   */
  dragStart(event) {
    this.dragInfo.isDragging = true;
    this.dragInfo.startX = event.clientX;
    this.dragInfo.startY = event.clientY;
  }
  /**
   * ドラッグで画像を移動する
   * @param {MouseEvent} event マウスイベント
   * @return {void}
   */
  drag(event) {
    if (this.dragInfo.isDragging) {
      // 開始位置 + 差分 / スケール (画像の大きさによる移動距離の補正のためスケールで割る)
      this.dragInfo.diffX = this.dragInfo.canvasX + (event.clientX - this.dragInfo.startX) / this.scale;
      this.dragInfo.diffY = this.dragInfo.canvasY + (event.clientY - this.dragInfo.startY) / this.scale;

      this._redraw();
    }
  }
  /**
   * ドラッグ終了
   * @param {MouseEvent} event マウスイベント
   * @return {void}
   */
  dragEnd(event) {
    this.dragInfo.isDragging = false;
    // mousedown時のカクつきをなくすため
    this.dragInfo.canvasX = this.dragInfo.diffX;
    this.dragInfo.canvasY = this.dragInfo.diffY;
  }

  /**
   * canvasを出力する
   * @return {Canvas}
   */
  getCanvas() {
    return this.canvas;
  }

  /**
   * imgを出力する
   * @return {Image}
   */
  getImage() {
    const img = new Image();
    const data = this.canvas.toDataURL('image/png');
    img.src = data;

    return img;
  }
}


const imageEditor = new ImageEditor({
  imageSrc: 'https://ja.wikipedia.org/static/images/project-logos/jawiki.png',
  canvasSize: 128
});
imageEditor.insertTo(document.getElementById('image'));

ちょっとした違いは、dragメソッド内で前回描画位置 + (現在のマウスの位置 - 開始位置) / 倍率のところ。
移動させる距離を倍率で割ることで、倍率に見合った移動距離に変換している。

_redraw()メソッドで毎回倍率を設定することで、現在の画像サイズを保ったままドラッグできるようにしている。



完成!!



参考サイト




以上

written by @bc_rikko

3 件のコメント :

  1. はじめまして。
    こういったものが作りたかったのですが難しかったので、解説して下さっている方がいらしてありがたいです。ありがとうございます!
    IEでも動かせるようにしたいのですが、そういった場合はclassの代わりに何を使えば良いでしょうか?差し支えなければ教えて頂けると嬉しいです。

    返信削除
    返信
    1. コメントありがとうございます。

      > IEでも動かせるようにしたいのですが、そういった場合はclassの代わりに何を使えば良いでしょうか?

      いくつか方法はありますが、一番簡単なのはクラス変数やクラスメソッドを外に出して実装することです。
      ただ、スコープが広くなるのでメンテが面倒かもしれません。

      ```js
      // 古いIEを使う場合はconstやletを全部varにしてください
      const src = imageSrc;
      const id = 'image-for-edit';
      // ...
      let scale = 1;
      const dragInfo = {
      // 略
      }

      // クラスメソッドをfunctionで定義する
      function insertTo(el) {
      // 略
      }

      function _redraw() {
      // 略
      }

      // 中略

      function createImageEditor() {
      // 略
      }

      function main() {
      // 略
      }

      main();
      ```


      もう1つは、もしJavaScritpについてある程度知識をお持ちでしたらprotoypeで実装する方法もあります。
      ```js
      // コンストラクタ
      function ImageEditor(opt) {
      this.src = '...';
      this.id = '...';
      // 略
      }

      // クラスメソッド
      ImageEditor.prototype.insertTo = function (el) {
      // 略
      };

      ImageEditor.prototype.redraw = function () {
      // 略
      };

      function main () {
      var imageEditor = new ImageEditor({ ... });
      }

      main();
      ```

      お好きな方で試してみてください。

      削除
    2. 教えて下さりありがとうございます。
      私の力量では、まだどちらの方法も使い方を理解できなかったので、いつか理解できるようjsの勉強を進めていこうと思います。
      この度はありがとうございました!

      削除