From 9f9fe32063607b04f3074985b7fc75b6bab9fada Mon Sep 17 00:00:00 2001 From: "Luis A. Leiva" Date: Tue, 2 Feb 2016 10:03:17 +0100 Subject: [PATCH] Added jQuery-free versions --- sketchable.js | 470 ++++++++++++++++++++++++++++++++++++++++++ sketchable.memento.js | 237 +++++++++++++++++++++ sketchable.utils.js | 73 +++++++ 3 files changed, 780 insertions(+) create mode 100644 sketchable.js create mode 100644 sketchable.memento.js create mode 100644 sketchable.utils.js 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; +};