(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 });
})));