diff --git a/sketchable.js b/sketchable.js
new file mode 100644
index 0000000..d49bda8
--- /dev/null
+++ b/sketchable.js
@@ -0,0 +1,470 @@
+/*!
+ * sketchable | v1.8 | Luis A. Leiva | MIT license
+ * A plugin for the jSketch drawing library.
+ */
+/*
+ Requires sketchable.utils.js to be loaded first.
+ globals: Event, dataBind, deepExtend.
+*/
+;(function(window){
+ // Custom namespace ID.
+ var _ns = "sketchable";
+ /**
+ * Creates a sketchable instance.
+ * This is a plugin for the jSketch drawing class.
+ * @param {String|Object} method - Method to invoke, or a configuration object.
+ * @return jSketchable
+ * @class
+ * @version 1.8
+ * @date 9 Jul 2014
+ * @author Luis A. Leiva
+ * @license MIT license
+ * @example
+ * var canvas = document.getElementById('foo');
+ * var sketcher = new Sketchable(canvas, {interactive:false});
+ * @see methods
+ */
+ /**
+ * @constructor
+ * @param {Object} elem - MUST be a DOM element
+ * @param {Object} options - Configuration
+ */
+ var jSketchable = function(elem, options) {
+ return new Sketchable(elem, options);
+ };
+
+ var Sketchable = function(elem, options) {
+ // Although discouraged, we can instantiate the class without arguments.
+ if (!elem) return;
+ this.elem = elem;
+ // We can pass default setup values.
+ if (typeof options === 'undefined') options = {};
+ // Instantiate the class.
+ return this.init(options);
+ };
+
+ /**
+ * jSketchable methods (publicly extensible).
+ * @ignore
+ * @memberof jSketchable
+ * @see jSketchable
+ */
+ jSketchable.fn = Sketchable.prototype = {
+ /**
+ * Initializes the selected objects.
+ * @param {Object} opts plugin configuration (see defaults).
+ * @return jSketchable
+ * @ignore
+ * @namespace methods.init
+ * @example $(selector).sketchable();
+ */
+ init: function(opts) {
+ // Options will be available for all plugin methods.
+ var options = deepExtend(jSketchable.fn.defaults, opts || {});
+ var elem = this.elem, data = dataBind(elem)[_ns];
+ // Check if element is not initialized yet.
+ if (!data) {
+ // Attach event listeners.
+ if (options.interactive) {
+ Event.add(elem, "mousedown", mousedownHandler);
+ Event.add(elem, "mousemove", mousemoveHandler);
+ Event.add(elem, "mouseup", mouseupHandler);
+ Event.add(elem, "touchstart", touchdownHandler);
+ Event.add(elem, "touchmove", touchmoveHandler);
+ Event.add(elem, "touchend", touchupHandler);
+ // Fix Chrome "bug".
+ this.onselectstart = function(){ return false };
+ }
+ if (options.cssCursors) {
+ // Visually indicate whether this element is interactive or not.
+ elem.style.cursor = options.interactive ? "pointer" : "not-allowed";
+ }
+ }
+ var sketch = new jSketch(elem, options.graphics);
+ // Reconfigure element data.
+ dataBind(elem)[_ns] = {
+ // All strokes will be stored here.
+ strokes: [],
+ // This will store one stroke per touching finger.
+ coords: {},
+ // Date of first coord, used as time origin.
+ timestamp: (new Date).getTime(),
+ // Save a pointer to the drawing canvas (jSketch instance).
+ sketch: sketch,
+ // Save also a pointer to the given options.
+ options: options
+ };
+ // Trigger init event.
+ if (typeof options.events.init === 'function') {
+ options.events.init(elem, dataBind(elem)[_ns]);
+ }
+ // Make methods chainable.
+ return this;
+ },
+ /**
+ * Changes config on the fly of an existing sketchable element.
+ * @param {Object} opts - Plugin configuration (see defaults).
+ * @return jQuery
+ * @namespace methods.config
+ * @example
+ * $(selector).sketchable('config', { interactive: false }); // Later on:
+ * $(selector).sketchable('config', { interactive: true });
+ */
+ config: function(opts) {
+ var elem = this.elem, data = dataBind(elem)[_ns];
+ data.options = deepExtend(jSketchable.fn.defaults, opts || {});
+ return this;
+ },
+ /**
+ * Gets/Sets drawing data strokes sequence.
+ * @param {Array} arr - Multidimensional array of [x,y,time,status] tuples; status = 0 (pen down) or 1 (pen up).
+ * @return Strokes object on get, jSketchable on set (with the new data attached)
+ * @namespace methods.strokes
+ * @example
+ * $(selector).sketchable('strokes'); // Getter
+ * $(selector).sketchable('strokes', [ [arr1], ..., [arrN] ]); // Setter
+ */
+ strokes: function(arr) {
+ var elem = this.elem;
+ if (arr) { // setter
+ var data = dataBind(elem)[_ns];
+ data.strokes = arr;
+ return this;
+ } else { // getter
+ var data = dataBind(elem)[_ns];
+ return data.strokes;
+ }
+ },
+ /**
+ * Allows low-level manipulation of the sketchable canvas.
+ * @param {Function} callback - Callback function, invoked with 2 arguments: elem (jSketchable element) and data (jSketchable element data).
+ * @return jSketchable
+ * @namespace methods.handler
+ * @example
+ * $(selector).sketchable('handler', function(elem, data){
+ * // do something with elem or data
+ * });
+ */
+ handler: function(callback) {
+ var elem = this.elem, data = dataBind(elem)[_ns];
+ callback(elem, data);
+ return this;
+ },
+ /**
+ * Clears canvas (together with strokes data).
+ * If you need to clear canvas only, just invoke data.sketch.clear() via $(selector).sketchable('handler').
+ * @see methods.handler
+ * @return jSketchable
+ * @namespace methods.clear
+ * @example $(selector).sketchable('clear');
+ */
+ clear: function() {
+ var elem = this.elem, data = dataBind(elem)[_ns], options = data.options;
+ data.sketch.clear();
+ data.strokes = [];
+ data.coords = {};
+
+ if (typeof options.events.clear === 'function') {
+ options.events.clear(elem, data);
+ }
+ return this;
+ },
+ /**
+ * Reinitializes a sketchable canvas with given opts.
+ * @param {Object} opts - Configuration options.
+ * @return jSketchable
+ * @namespace methods.reset
+ * @example
+ * $(selector).sketchable('reset');
+ * $(selector).sketchable('reset', {interactive:false});
+ */
+ reset: function(opts) {
+ var elem = this.elem, data = dataBind(elem)[_ns], options = data.options;
+ this.destroy().init(opts);
+
+ if (typeof options.events.reset === 'function') {
+ options.events.reset(elem, data);
+ }
+ return this;
+ },
+ /**
+ * Destroys sketchable canvas (together with strokes data and events).
+ * @return jSketchable
+ * @namespace methods.destroy
+ * @example $(selector).sketchable('destroy');
+ */
+ destroy: function() {
+ var elem = this.elem, data = dataBind(elem)[_ns], options = data.options;
+ if (options.interactive) {
+ Event.remove(elem, "mouseup", mouseupHandler);
+ Event.remove(elem, "mousemove", mousemoveHandler);
+ Event.remove(elem, "mousedown", mousedownHandler);
+ Event.remove(elem, "touchstart", touchdownHandler);
+ Event.remove(elem, "touchmove", touchmoveHandler);
+ Event.remove(elem, "touchend", touchupHandler);
+ }
+ dataBind(elem)[_ns] = null;
+
+ if (typeof options.events.destroy === 'function') {
+ options.events.destroy(elem, data);
+ }
+ return this;
+ }
+
+ };
+
+ /**
+ * Default configuration.
+ * Note that mouse* callbacks are triggered only if interactive is set to true.
+ * @name defaults
+ * @default
+ * @memberof $.fn.sketchable
+ * @example
+ * $(selector).sketchable({
+ * interactive: true,
+ * mouseupMovements: false,
+ * relTimestamps: false,
+ * multitouch: false,
+ * cssCursors: true,
+ * events: {
+ * init: function(elem, data){ },
+ * clear: function(elem, data){ },
+ * destroy: function(elem, data){ },
+ * mousedown: function(elem, data, evt){ },
+ * mousemove: function(elem, data, evt){ },
+ * mouseup: function(elem, data, evt){ },
+ * },
+ * graphics: {
+ * firstPointSize: 3,
+ * lineWidth: 3,
+ * strokeStyle: '#F0F',
+ * fillStyle: '#F0F',
+ * lineCap: "round",
+ * lineJoin: "round",
+ * miterLimit: 10
+ * }
+ * });
+ */
+ jSketchable.fn.defaults = {
+ // In interactive mode, it's possible to draw via mouse/pen/touch input.
+ interactive: true,
+ // Indicate whether non-drawing strokes should be registered as well.
+ // Notice that the last mouseUp stroke is never recorded, as the user has already finished drawing.
+ mouseupMovements: false,
+ // Inidicate whether timestamps should be relative (start at time 0) or absolute (start at Unix epoch).
+ relTimestamps: false,
+ // Enable multitouch drawing.
+ multitouch: true,
+ // Display CSS cursors, mainly to indicate whether the element is interactive or not.
+ cssCursors: true,
+ // Event callbacks.
+ events: {
+ // init: function(elem, data){ },
+ // clear: function(elem, data){ },
+ // destroy: function(elem, data){ },
+ // mousedown: function(elem, data, evt){ },
+ // mousemove: function(elem, data, evt){ },
+ // mouseup: function(elem, data, evt){ },
+ },
+ graphics: {
+ firstPointSize: 3,
+ lineWidth: 3,
+ strokeStyle: '#F0F',
+ fillStyle: '#F0F',
+ lineCap: "round",
+ lineJoin: "round",
+ miterLimit: 10
+ }
+ };
+
+ function offset(el) {
+ var box = el.getBoundingClientRect();
+ var body = document.body;
+ var docElem = document.documentElement;
+ var scrollTop = window.pageYOffset || docElem.scrollTop || body.scrollTop;
+ var scrollLeft = window.pageXOffset || docElem.scrollLeft || body.scrollLeft;
+ var clientTop = docElem.clientTop || body.clientTop || 0;
+ var clientLeft = docElem.clientLeft || body.clientLeft || 0;
+ var top = box.top + scrollTop - clientTop;
+ var left = box.left + scrollLeft - clientLeft;
+ return {
+ top: Math.round(top),
+ left: Math.round(left)
+ }
+ };
+
+ /**
+ * @private
+ */
+ function getMousePos(e) {
+ var elem = e.target, pos = offset(elem);
+ return {
+ x: Math.round(e.pageX - pos.left),
+ y: Math.round(e.pageY - pos.top)
+ }
+ };
+
+ /**
+ * @private
+ */
+ function saveMousePos(idx, data, pt) {
+ // Ensure that coords is properly initialized.
+ if (!data.coords[idx]) {
+ data.coords[idx] = [];
+ }
+
+ var time = (new Date).getTime();
+ if (data.options.relTimestamps) {
+ // The first timestamp is relative to initialization time;
+ // thus fix it so that it is relative to the timestamp of the first stroke.
+ if (data.strokes.length === 0 && data.coords[idx].length === 0) data.timestamp = time;
+ time -= data.timestamp;
+ }
+
+ data.coords[idx].push([ pt.x, pt.y, time, +data.sketch.isDrawing ]);
+ };
+
+ /**
+ * @private
+ */
+ function mousedownHandler(e) {
+ if (e.touches) return false;
+ downHandler(e);
+ };
+
+ /**
+ * @private
+ */
+ function mousemoveHandler(e) {
+ if (e.touches) return false;
+ moveHandler(e);
+ };
+
+ /**
+ * @private
+ */
+ function mouseupHandler(e) {
+ if (e.touches) return false;
+ upHandler(e);
+ };
+
+ function execTouchEvent(e, callback) {
+ var elem = e.target, data = dataBind(elem)[_ns], options = data.options;
+ var touches = e.changedTouches;
+ if (options.multitouch) {
+ for (var i = 0; i < touches.length; i++) {
+ var touch = touches[i];
+ // Add the type of event to the touch object.
+ touch.type = e.type;
+ callback(touch);
+ }
+ } else {
+ var touch = touches[0];
+ // Add the type of event to the touch object.
+ touch.type = e.type;
+ callback(touch);
+ }
+ };
+
+ /**
+ * @private
+ */
+ function touchdownHandler(e) {
+ execTouchEvent(e, downHandler);
+ e.preventDefault();
+ };
+
+ /**
+ * @private
+ */
+ function touchmoveHandler(e) {
+ execTouchEvent(e, moveHandler);
+ e.preventDefault();
+ };
+
+ /**
+ * @private
+ */
+ function touchupHandler(e) {
+ execTouchEvent(e, upHandler);
+ e.preventDefault();
+ };
+
+ /**
+ * @private
+ */
+ function downHandler(e) {
+ // Don't handle right clicks.
+ if (Event.isRightClick(e)) return false;
+
+ var idx = e.identifier || 0;
+ var elem = e.target, data = dataBind(elem)[_ns], options = data.options;
+ // Exit early if interactivity is disabled.
+ if (!options.interactive) return;
+
+ data.sketch.isDrawing = true;
+ var p = getMousePos(e);
+ // Mark visually 1st point of stroke.
+ if (options.graphics.firstPointSize > 0) {
+ data.sketch.fillCircle(p.x, p.y, options.graphics.firstPointSize);
+ }
+ // Ensure that coords is properly initialized.
+ if (!data.coords[idx]) {
+ data.coords[idx] = [];
+ }
+ // Don't mix mouseup and mousedown in the same stroke.
+ if (data.coords[idx].length > 0) {
+ data.strokes.push(data.coords[idx]);
+ data.coords[idx] = [];
+ }
+ saveMousePos(idx, data, p);
+
+ if (typeof options.events.mousedown === 'function') {
+ options.events.mousedown(elem, data, e);
+ }
+ };
+
+ /**
+ * @private
+ */
+ function moveHandler(e) {
+ var idx = e.identifier || 0;
+ var elem = e.target, data = dataBind(elem)[_ns], options = data.options;
+ if (!options.interactive) return;
+ //if (!options.mouseupMovements && !data.sketch.isDrawing) return;
+ // This would grab all penup strokes AFTER drawing something on the canvas for the first time.
+ if ( (!options.mouseupMovements || data.strokes.length === 0) && !data.sketch.isDrawing ) return;
+
+ var p = getMousePos(e);
+ if (data.sketch.isDrawing) {
+ var last = data.coords[idx][ data.coords[idx].length - 1 ];
+ data.sketch.beginPath().line(last[0], last[1], p.x, p.y).stroke().closePath();
+ }
+ saveMousePos(idx, data, p);
+
+ if (typeof options.events.mousemove === 'function') {
+ options.events.mousemove(elem, data, e);
+ }
+ };
+
+ /**
+ * @private
+ */
+ function upHandler(e) {
+ var idx = e.identifier || 0;
+ var elem = e.target, data = dataBind(elem)[_ns], options = data.options;
+ if (!options.interactive) return;
+
+ data.sketch.isDrawing = false;
+ data.strokes.push(data.coords[idx]);
+ data.coords[idx] = [];
+
+ if (typeof options.events.mouseup === 'function') {
+ options.events.mouseup(elem, data, e);
+ }
+ };
+
+ // Expose.
+ window.Sketchable = jSketchable;
+
+})(this);
diff --git a/sketchable.memento.js b/sketchable.memento.js
new file mode 100644
index 0000000..ac2d2e9
--- /dev/null
+++ b/sketchable.memento.js
@@ -0,0 +1,237 @@
+/*!
+ * Memento plugin for sketchable | v1.1 | Luis A. Leiva | MIT license
+ */
+/*
+ Requires sketchable.utils.js to be loaded first.
+ globals: Event, dataBind, deepExtend.
+*/
+;(function(window) {
+ /**
+ * @name MementoCanvas
+ * @class
+ * @return Object
+ * @example
+ * var mc = new MementoCanvas( $('canvas-selector') );
+ */
+
+// // TODO: Extend Sketchable class.
+// MementoCanvas.prototype = new Sketchable(elem, opts);
+// MementoCanvas.constructor = Sketchable;
+
+ var MementoCanvas = function(sketchable) {
+
+ // Private stuff //////////////////////////////////////////////////////////
+ var stack = [];
+ var stpos = -1;
+ var self = this;
+
+ function prev() {
+ if (stpos > 0) {
+ stpos--;
+ var snapshot = new Image();
+ snapshot.src = stack[stpos].image;
+ snapshot.onload = function() {
+ restore(this);
+ };
+ }
+ };
+
+ function next() {
+ if (stpos < stack.length - 1) {
+ stpos++;
+ var snapshot = new Image();
+ snapshot.src = stack[stpos].image;
+ snapshot.onload = function() {
+ restore(this);
+ };
+ }
+ };
+
+ function restore(snapshot) {
+ // Manipulate canvas via jQuery sketchable API.
+ // This way, we don't lose default drawing settings et al.
+ sketchable.handler(function(elem, data){
+ //data.sketch.clear().drawImage(snapshot.src);
+ // Note: jSketch.drawImage after clear creates some flickering,
+ // so use the native HTMLCanvasElement.drawImage method instead.
+ data.sketch.clear();
+ data.sketch.graphics.drawImage(snapshot, 0,0);
+ });
+ };
+
+ // Key event manager.
+ // Undo: "Ctrl + Z"
+ // Redo: "Ctrl + Y" or "Ctrl + Shift + Z"
+ // TODO: decouple shortcut definition.
+ function keyManager(e) {
+ if (e.ctrlKey) {
+ switch (e.which) {
+ case 26: // Z
+ if (e.shiftKey) sketchable.redo();
+ else sketchable.undo();
+ break;
+ case 25: // Y
+ sketchable.redo();
+ break;
+ default:
+ break;
+ }
+ }
+ };
+
+ // Public stuff ///////////////////////////////////////////////////////////
+
+ /**
+ * Goes back to the last saved state, if available.
+ * @name undo
+ * @memberOf MementoCanvas
+ */
+ this.undo = function() {
+ prev();
+ sketchable.handler(function(elem, data) {
+ if (stack[stpos])
+ data.strokes = stack[stpos].strokes.slice();
+ });
+ };
+ /**
+ * Goes forward to the last saved state, if available.
+ * @name redo
+ * @memberOf MementoCanvas
+ */
+ this.redo = function() {
+ next();
+ sketchable.handler(function(elem, data) {
+ if (stack[stpos])
+ data.strokes = stack[stpos].strokes.slice();
+ });
+ };
+ /**
+ * Resets stack.
+ * @name reset
+ * @memberOf MementoCanvas
+ */
+ this.reset = function() {
+ stack = [];
+ stpos = -1;
+ };
+ /**
+ * Save state.
+ * @name save
+ * @memberOf MementoCanvas
+ */
+ this.save = function() {
+ stpos++;
+ if (stpos < stack.length) stack.length = stpos;
+ sketchable.handler(function(elem, data) {
+ stack.push({ image: elem.toDataURL(), strokes: data.strokes.slice() });
+ });
+ };
+ /**
+ * Init instance.
+ * @name init
+ * @memberOf MementoCanvas
+ */
+ this.init = function() {
+ // Prevent subsequent instances to re-attach this event to the document.
+ Event.remove(document, "keypress", keyManager);
+ Event.add(document, "keypress", keyManager);
+ };
+ /**
+ * Destroy instance.
+ * @name destroy
+ * @memberOf MementoCanvas
+ */
+ this.destroy = function() {
+ Event.remove(document, "keypress", keyManager);
+ this.reset();
+ };
+
+ };
+
+ // Bind plugin extension ////////////////////////////////////////////////////
+ var namespace = "sketchable";
+ var availMethods = Sketchable.fn;
+ var defaults = Sketchable.fn.defaults;
+
+ function configure(sketchable, opts) {
+ var options = deepExtend(defaults, opts);
+ // Actually this plugin is singleton, so exit early.
+ if (!options.interactive) return opts;
+
+ var elem = sketchable.elem;
+
+ var callbacks = {
+ init: function(elem, data) {
+ data.memento = new MementoCanvas(sketchable);
+ data.memento.save();
+ data.memento.init();
+ },
+ clear: function(elem, data) {
+ data.memento.save();
+ },
+ mouseup: function(elem, data, e) {
+ data.memento.save();
+ },
+ destroy: function(elem, data) {
+ data.memento.destroy();
+ }
+ };
+
+ // A helper function to override user-defined event listeners.
+ function override(ev) {
+ if (options && options.events && typeof options.events[ev] === 'function') {
+ var fn = options.events[ev];
+ options.events[ev] = function() {
+ // Exec original function first, then exec our callback.
+ var args = Array.prototype.slice.call(arguments, 0);
+ fn.apply(elem, args);
+ callbacks[ev].apply(elem, args);
+ }
+ } else {
+ defaults.events[ev] = callbacks[ev];
+ }
+ };
+
+ // Avoid re-attaching the same callbacks more than once.
+ if (!availMethods.isMementoReady) {
+ // Event order matters.
+ var events = 'init mouseup clear destroy'.split(" ");
+ for (var i = 0; i < events.length; i++) {
+ override(events[i]);
+ }
+ availMethods.isMementoReady = true;
+ }
+
+ // Expose public API for sketchable plugin.
+ deepExtend(availMethods, {
+ undo: function() {
+ var elem = this.elem, data = dataBind(elem)[namespace];
+ data.memento.undo();
+ },
+ redo: function() {
+ var elem = this.elem, data = dataBind(elem)[namespace];
+ data.memento.redo();
+ }
+ });
+
+ return options;
+ };
+
+ /**
+ * Creates a new memento-capable jQuery.sketchable object.
+ * @param {String|Object} method name of the method to invoke,
+ * or a configuration object.
+ * @return jQuery
+ * @class
+ * @example
+ * $(selector).sketchable();
+ * $(selector).sketchable({interactive:false});
+ */
+ var initfn = availMethods.init;
+ availMethods.init = function(opts) {
+ var conf = configure(this, opts);
+ initfn.call(this, conf);
+ return this;
+ };
+
+})(this);
diff --git a/sketchable.utils.js b/sketchable.utils.js
new file mode 100644
index 0000000..961edea
--- /dev/null
+++ b/sketchable.utils.js
@@ -0,0 +1,73 @@
+/**
+ * Data binding lib.
+ */
+(function(){
+ var cache = [0],
+ expando = 'data' + +(new Date);
+ function data(elem) {
+ var cacheIndex = elem[expando],
+ nextCacheIndex = cache.length;
+ if (!cacheIndex) {
+ cacheIndex = elem[expando] = nextCacheIndex;
+ cache[cacheIndex] = {};
+ }
+ return cache[cacheIndex];
+ };
+ window.dataBind = data;
+})();
+
+/**
+ * Event manager.
+ */
+var Event = {
+
+ add: function(elem, type, fn) {
+ if (!elem) return false;
+ if (elem.addEventListener) { // W3C standard
+ elem.addEventListener(type, fn, false);
+ } else if (elem.attachEvent) { // IE versions
+ elem.attachEvent("on"+type, fn);
+ } else { // Really old browser
+ elem[type+fn] = function(){ fn(window.event); };
+ }
+ },
+
+ remove: function(elem, type, fn) {
+ if (!elem) return false;
+ if (elem.removeEventListener) { // W3C standard
+ elem.removeEventListener(type, fn, false);
+ } else if (elem.detachEvent) { // IE versions
+ elem.detachEvent("on"+type, fn);
+ } else { // Really old browser
+ elem[type+fn] = null;
+ }
+ },
+
+ isRightClick: function(ev) {
+ if (!ev) ev = window.event;
+ if (ev.which) return ev.which === 3;
+ else if (ev.button) return e.button === 2;
+ return false;
+ }
+
+};
+
+/**
+ * A handy method to (deep) extend an object.
+ */
+var deepExtend = function(myObj) {
+ myObj = myObj || {};
+ for (var i = 1; i < arguments.length; i++) {
+ var obj = arguments[i];
+ if (!obj) continue;
+ for (var key in obj) {
+ if (obj.hasOwnProperty(key)) {
+ if (typeof obj[key] === 'object')
+ deepExtend(myObj[key], obj[key]);
+ else
+ myObj[key] = obj[key];
+ }
+ }
+ }
+ return myObj;
+};