immaterial-design/immaterial-design-ripple

View on GitHub
src/index.js

Summary

Maintainability
C
1 day
Test Coverage
import EventEmitter from 'events';
import Promise from 'bluebird';
import * as util from './utility';

import JSON5 from 'json5';
import objectAssign from 'object-assign';

export default class ImdRipple extends EventEmitter {
  /**
  * アニメーションで使用するユーティリティ関数群の参照
  *
  * @static
  * @public
  * @property ImdRipple.util
  */
  static get util() {
    return util;
  }

  /**
  * ページの読み込み時にインスタンスを自動で生成する
  *
  * @static
  * @public
  * @method ImdRipple.bindOnLoad
  */
  static bindOnLoad(selector, options = {}) {
    return new Promise((resolve) => {
      window.addEventListener('load', () => {
        const elements = [].slice.call(document.querySelectorAll(selector));

        resolve(elements.map((element) => new ImdRipple(element, options)));
      });
    });
  }

  /**
  * 指定の要素のクリック時にアニメーションするイベントを追加する
  *
  * @class ImdRipple
  * @constructor
  * @param {Element} element - クリックイベントを監視する要素。アニメ時子要素としてCanvasを追加する
  * @param {Object} [options] - this.playの引数
  */
  constructor(element, options = {}) {
    super();

    /**
    * @public
    * @property {HTMLElement} element
    */
    this.element = element;

    // 親要素と全く同じ大きさのcanvas要素であることを期待する
    // そのため、position:staticプロパティを使用しない
    const position = window.getComputedStyle(element).getPropertyValue('position');
    if (position === 'static') {
      this.element.style.position = 'relative';
    }

    // mouseup/touchendでキャンバスの透明化を開始する
    this.element.addEventListener('mousedown', (event) => {
      if (event.which !== 1) {// only left click
        return;
      }

      const { left, top } = this.element.getBoundingClientRect();
      const x = Math.floor(event.clientX - left);
      const y = Math.floor(event.clientY - top);

      this.emit('begin');
      this.play(x, y, objectAssign({ exitBefore: 'mouseup' }, options))
      .then(() => {
        this.emit('end');
      });
    });

    this.element.addEventListener('touchstart', (event) => {
      const { left, top } = this.element.getBoundingClientRect();
      const x = Math.floor(event.changedTouches[0].clientX - left);
      const y = Math.floor(event.changedTouches[0].clientY - top);

      this.emit('begin');
      this.play(x, y, objectAssign({ exitBefore: 'touchend' }, options))
      .then(() => {
        this.emit('end');
      });
    });
  }

  /**
  * this.elementに直接定義したオプションを返す
  *
  * @public
  * @method getOptions
  * @param {String} attrName 取得し、json5としてパースする属性名
  * @return {Object} options オプション
  */
  getOptions(attrName = 'imd-options') {
    return JSON5.parse(this.element.getAttribute(attrName) || '{}');
  }

  /**
  * コンストラクタの要素内で波形アニメーションを再生する
  *
  * @public
  * @method ImdRipple#play
  * @param {Number} [x=auto] 波形アニメーションの始点x
  * @param {Number} [y=auto] 波形アニメーションの始点y
  * @param {Object} [options] ImdRipple.rippleで使用する引数
  * @param {String|Bool} [options.exitBefore] canvasを破棄するタイミングの指定
  * @return {Promise} ImdRipple.play参照
  */
  play(x, y, options = {}) {
    const opts = objectAssign({
      exitBefore: true, // auto
    }, this.getOptions(), options);

    const { width, height } = this.element.getBoundingClientRect();
    const playX = x === undefined ? Math.floor(width / 2) : x;
    const playY = y === undefined ? Math.floor(height / 2) : y;

    const animation = ImdRipple.play(playX, playY, width, height, opts);

    this.element.appendChild(animation.context.canvas);

    let exit;
    if (typeof opts.exitBefore === 'string') {
      exit = util.promiseEvent(window, opts.exitBefore);
    } else if (opts.exitBefore === true) {
      exit = animation;// アニメーション終了時に事後処理
    }

    return exit
    .then(() => util.transparentize(animation.context.canvas, opts))
    .then(() => animation.stop());
  }

  /**
  * CanvasRenderingContext2Dを作成して波形アニメーションを再生する
  * 全てのピクセルの描写を終えるまでcanvasを更新し続ける
  * キャンバスが大きいほど負荷が高いので、更新の必要がなければ停止する
  * 全てのピクセルが描写した時か、promise.stopを実行した時に、fulfillする
  *
  * @static
  * @public
  * @method ImdRipple.play
  * @param {Number} x 波形アニメーションの始点x
  * @param {Number} y 波形アニメーションの始点y
  * @param {Number} width 波形アニメーションの幅
  * @param {Number} height 波形アニメーションの高さ
  * @param {Object} [options]
  * @param {Number} [options.pixelSize=height/15] ピクセル1粒の大きさ
  * @return {Promise<CanvasRenderingContext2D>} animation 独自の2プロパティを持つ
  */
  static play(x, y, width, height, options = {}) {
    const opts = objectAssign({
      pixelSize: Math.floor(height / 10),
      bitCrash: 7,
      pixelated: true,
    }, options);

    const context = util.createContext2d(width, height, opts);
    const schedule = util.createRenderSchedule(x, y, width, height, opts);
    const imageData = util.getImageData(context.canvas);
    const [r, g, b, a] = util.getPixelColor(opts.color);

    const promise = new Promise((resolve) => {
      let frame = 0;
      const render = () => {
        if (promise.disabled) {
          return resolve(context);
        }

        let rendered = true;
        let index = 0;
        for (let i = 0; i < context.canvas.height; i++) {
          for (let j = 0; j < context.canvas.width; j++) {
            const pixelX = Math.floor(j / schedule.pixelSize);
            const pixelY = Math.floor(i / schedule.pixelSize);
            const show = schedule.data[pixelY][pixelX] <= frame;
            if (show === false) {
              rendered = false;
            }

            imageData.data[index + 0] = r;
            imageData.data[index + 1] = g;
            imageData.data[index + 2] = b;
            imageData.data[index + 3] = show ? a : 0;
            index += 4;
          }
        }
        context.putImageData(imageData, 0, 0);
        if (rendered) {
          return resolve(context);
        }

        frame += 1;
        return util.requestAnimationFrame(render);
      };

      util.requestAnimationFrame(render);
    });

    // FIXME:
    //   Promiseとcontextを同時返したい。
    //   (mouseupイベントで透明化を始めたいので)
    promise.context = context;
    promise.stop = function stopAnimation() {
      promise.disabled = true;
      return this;
    };

    return promise;
  }
}