首页 > 技术文章 > simple-drawing-board.js

zhoudawei 2021-01-19 21:51 原文

(function (global, factory) {
  typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
  typeof define === 'function' && define.amd ? define(['exports'], factory) :
  (global = global || self, factory(global.SimpleDrawingBoard = {}));
}(this, (function (exports) { 'use strict';

  /**
   *
   * Minimul EventEmitter implementation
   * See `https://gist.github.com/leader22/3ab8416ce41883ae1ccd`
   *
   */
  class Eve {
    constructor() {
      this._events = {};
    }

    on(evName, handler) {
      const events = this._events;

      if (!(evName in events)) {
        events[evName] = [];
      }
      events[evName].push(handler);
    }

    off(evName, handler) {
      const events = this._events;

      if (!(evName in events)) {
        return;
      }
      if (!handler) {
        events[evName] = [];
      }

      const handlerIdx = events[evName].indexOf(handler);
      if (handlerIdx >= 0) {
        events[evName].splice(handlerIdx, 1);
      }
    }

    trigger(evName, evData) {
      const events = this._events;

      if (!(evName in events)) {
        return;
      }

      for (let i = 0; i < events[evName].length; i++) {
        const handler = events[evName][i];
        handler.handleEvent
          ? handler.handleEvent.call(this, evData)
          : handler.call(this, evData);
      }
    }

    removeAllListeners() {
      this._events = {};
    }
  }

  /**
   *
   * History for undo/redo Structure(mutable)
   * See `https://gist.github.com/leader22/9fbed07106d652ef40fda702da4f39c4`
   *
   */
  class History {
    constructor(initialValue = null) {
      this._past = [];
      this._present = initialValue;
      this._future = [];
    }

    get value() {
      return this._present;
    }

    undo() {
      if (this._past.length === 0) return;

      const previous = this._past.pop();
      this._future.unshift(this._present);
      this._present = previous;
    }

    redo() {
      if (this._future.length === 0) return;

      const next = this._future.shift();
      this._past.push(this._present);
      this._present = next;
    }

    save(newPresent) {
      if (this._present === newPresent) return;

      this._past.push(this._present);
      this._future.length = 0;
      this._present = newPresent;
    }

    clear() {
      this._past.length = 0;
      this._future.length = 0;
    }
  }

  function isTouch() {
    return "ontouchstart" in window.document;
  }

  // expect HTML elements from CanvasImageSource
  function isDrawableElement($el) {
    if ($el instanceof HTMLImageElement) return true;
    if ($el instanceof SVGImageElement) return true;
    if ($el instanceof HTMLCanvasElement) return true;
    if ($el instanceof HTMLVideoElement) return true;
    return false;
  }

  function isBase64DataURL(url) {
    if (typeof url !== "string") return false;
    if (!url.startsWith("data:image/")) return false;
    return true;
  }

  async function loadImage(src) {
    return new Promise((resolve, reject) => {
      const img = new Image();
      img.onerror = reject;
      img.onload = () => resolve(img);
      img.src = src;
    });
  }

  function getMidInputCoords(old, coords) {
    return {
      x: (old.x + coords.x) >> 1,
      y: (old.y + coords.y) >> 1,
    };
  }

  function getInputCoords(ev, $el) {
    let x, y;
    if (isTouch()) {
      x = ev.touches[0].pageX;
      y = ev.touches[0].pageY;
    } else {
      x = ev.pageX;
      y = ev.pageY;
    }

    // check this every time for real-time resizing
    const elBCRect = $el.getBoundingClientRect();

    // need to consider scrolled positions
    const elRect = {
      left: elBCRect.left + window.pageXOffset,
      top: elBCRect.top + window.pageYOffset,
    };

    // if canvas has styled
    const elScale = {
      x: $el.width / elBCRect.width,
      y: $el.height / elBCRect.height,
    };

    return {
      x: (x - elRect.left) * elScale.x,
      y: (y - elRect.top) * elScale.y,
    };
  }

  class SimpleDrawingBoard {
    constructor($el) {
      this._$el = $el;
      this._ctx = this._$el.getContext("2d");

      // handwriting fashion ;D
      this._ctx.lineCap = this._ctx.lineJoin = "round";

      // for canvas operation
      this._isDrawMode = true;

      // for drawing
      this._isDrawing = false;
      this._timer = null;
      this._coords = {
        old: { x: 0, y: 0 },
        oldMid: { x: 0, y: 0 },
        current: { x: 0, y: 0 },
      };

      this._ev = new Eve();
      this._history = new History(this.toDataURL());

      this._bindEvents();
      this._drawFrame();
    }

    get canvas() {
      return this._$el;
    }

    get observer() {
      return this._ev;
    }

    get mode() {
      return this._isDrawMode ? "draw" : "erase";
    }

    setLineSize(size) {
      this._ctx.lineWidth = size | 0 || 1;
    }

    setLineColor(color) {
      this._ctx.strokeStyle = color;
    }

    fill(color) {
      const ctx = this._ctx;
      ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
      ctx.fillStyle = color;
      ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);

      this._saveHistory();
    }

    clear() {
      const ctx = this._ctx;
      ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);

      this._saveHistory();
    }

    toggleMode() {
      this._ctx.globalCompositeOperation = this._isDrawMode
        ? "destination-out"
        : "source-over";
      this._isDrawMode = !this._isDrawMode;
    }

    toDataURL({ type, quality } = {}) {
      return this._ctx.canvas.toDataURL(type, quality);
    }

    fillImageByElement($el, { isOverlay = false } = {}) {
      if (!isDrawableElement($el))
        throw new TypeError("Passed element is not a drawable!");

      const ctx = this._ctx;
      // if isOverlay is true, do not clear current canvas
      if (!isOverlay) ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
      ctx.drawImage($el, 0, 0, ctx.canvas.width, ctx.canvas.height);

      this._saveHistory();
    }

    async fillImageByDataURL(src, { isOverlay = false } = {}) {
      if (!isBase64DataURL(src))
        throw new TypeError("Passed src is not a base64 data URL!");

      const img = await loadImage(src);

      const ctx = this._ctx;
      // if isOverlay is true, do not clear current canvas
      if (!isOverlay) ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
      ctx.drawImage(img, 0, 0, ctx.canvas.width, ctx.canvas.height);

      this._saveHistory();
    }

    async undo() {
      this._history.undo();
      const base64 = this._history.value;
      if (!isBase64DataURL(base64)) return;

      const img = await loadImage(base64);

      const ctx = this._ctx;
      ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
      ctx.drawImage(img, 0, 0, ctx.canvas.width, ctx.canvas.height);
    }

    async redo() {
      this._history.redo();
      const base64 = this._history.value;
      if (!isBase64DataURL(base64)) return;

      const img = await loadImage(base64);

      const ctx = this._ctx;
      ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
      ctx.drawImage(img, 0, 0, ctx.canvas.width, ctx.canvas.height);
    }

    destroy() {
      this._unbindEvents();

      this._ev.removeAllListeners();
      this._history.clear();

      cancelAnimationFrame(this._timer);
      this._timer = null;
    }

    handleEvent(ev) {
      ev.preventDefault();
      ev.stopPropagation();

      switch (ev.type) {
        case "mousedown":
        case "touchstart":
          this._onInputDown(ev);
          break;
        case "mousemove":
        case "touchmove":
          this._onInputMove(ev);
          break;
        case "mouseup":
        case "touchend":
          this._onInputUp();
          break;
        case "mouseout":
        case "touchcancel":
        case "gesturestart":
          this._onInputCancel();
          break;
      }
    }

    _bindEvents() {
      const events = isTouch()
        ? ["touchstart", "touchmove", "touchend", "touchcancel", "gesturestart"]
        : ["mousedown", "mousemove", "mouseup", "mouseout"];

      for (const ev of events) {
        this._$el.addEventListener(ev, this, false);
      }
    }
    _unbindEvents() {
      const events = isTouch()
        ? ["touchstart", "touchmove", "touchend", "touchcancel", "gesturestart"]
        : ["mousedown", "mousemove", "mouseup", "mouseout"];

      for (const ev of events) {
        this._$el.removeEventListener(ev, this, false);
      }
    }

    _drawFrame() {
      this._timer = requestAnimationFrame(() => this._drawFrame());

      if (!this._isDrawing) return;

      const isSameCoords =
        this._coords.old.x === this._coords.current.x &&
        this._coords.old.y === this._coords.current.y;

      const currentMid = getMidInputCoords(
        this._coords.old,
        this._coords.current
      );
      const ctx = this._ctx;

      ctx.beginPath();
      ctx.moveTo(currentMid.x, currentMid.y);
      ctx.quadraticCurveTo(
        this._coords.old.x,
        this._coords.old.y,
        this._coords.oldMid.x,
        this._coords.oldMid.y
      );
      ctx.stroke();

      this._coords.old = this._coords.current;
      this._coords.oldMid = currentMid;

      if (!isSameCoords) this._ev.trigger("draw", this._coords.current);
    }

    _onInputDown(ev) {
      this._isDrawing = true;

      const coords = getInputCoords(ev, this._$el);
      this._coords.current = this._coords.old = coords;
      this._coords.oldMid = getMidInputCoords(this._coords.old, coords);

      this._ev.trigger("drawBegin", this._coords.current);
    }

    _onInputMove(ev) {
      this._coords.current = getInputCoords(ev, this._$el);
    }

    _onInputUp() {
      this._ev.trigger("drawEnd", this._coords.current);
      this._saveHistory();

      this._isDrawing = false;
    }

    _onInputCancel() {
      if (this._isDrawing) {
        this._ev.trigger("drawEnd", this._coords.current);
        this._saveHistory();
      }

      this._isDrawing = false;
    }

    _saveHistory() {
      this._history.save(this.toDataURL());
      this._ev.trigger("save", this._history.value);
    }
  }

  function create($el) {
    if (!($el instanceof HTMLCanvasElement))
      throw new TypeError("HTMLCanvasElement must be passed as first argument!");

    const sdb = new SimpleDrawingBoard($el);
    return sdb;
  }

  exports.create = create;

  Object.defineProperty(exports, '__esModule', { value: true });

})));

推荐阅读