jquery.sketchable.js

/*!
 * jQuery sketchable | v2.3 | Luis A. Leiva | MIT license
 * A jQuery plugin for the jSketch drawing library.
 */

/**
 * @method $
 * @description jQuery constructor. See {@link https://jquery.com}
 * @param {string} selector - jQuery selector.
 * @return {object} jQuery
 */
/**
 * @namespace $.fn
 * @description jQuery prototype. See {@link https://learn.jquery.com/plugins/}
 */

/* eslint-env browser */
/* global jQuery */
;(function($) {

  // Custom namespace ID, for private data bindind.
  var namespace = 'sketchable';

  // Begin jQuery Sketchable plugin API.
  var api = {
    /**
     * Initialize the selected jQuery objects.
     * @param {object} [opts] - Configuration (default: {@link $.fn.sketchable.defaults}).
     * @return {object} jQuery
     * @memberof $.fn.sketchable
     * @ignore
     * @protected
     */
    init: function(opts) {
      var options = $.extend(true, {}, $.fn.sketchable.defaults, opts || {});

      return this.each(function() {
        var elem = $(this), data = elem.data(namespace);
        // Check if element is not initialized yet.
        if (!data) {
          // Attach event listeners.
          elem.bind('mousedown', mousedownHandler);
          elem.bind('mousemove', mousemoveHandler);
          elem.bind('mouseup', mouseupHandler);
          elem.bind('touchstart', touchdownHandler);
          elem.bind('touchmove', touchmoveHandler);
          elem.bind('touchend', touchupHandler);

          postProcess(elem, options);
        }

        var sketch = new jSketch(this, options.graphics); // eslint-disable-line new-cap
        // Reconfigure element data.
        elem.data(namespace, {
          // All strokes will be stored here.
          strokes: [],
          // This will store one stroke per touching finger.
          coords: {},
          // Date of first coord, used as time origin.
          // Will be initialized on drawing the first stroke.
          timestamp: 0,
          // 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, elem.data(namespace));
        // Initialize plugins.
        for (var name in $.fn.sketchable.plugins)
          $.fn.sketchable.plugins[name](elem);
      });
    },
    /**
     * Get/Set user configuration of an existing jQuery Sketchable element.
     * @param {object} [opts] - Configuration (default: {@link $.fn.sketchable.defaults}).
     * @return {object} jQuery
     * @memberof $.fn.sketchable
     * @example
     * var $canvas = $('canvas').sketchable('config', { interactive: false });
     * // Update later on:
     * $canvas.sketchable('config', { interactive: true });
     */
    config: function(opts) {
      if (opts) { // setter
        return this.each(function() {
          var elem = $(this), data = elem.data(namespace);
          data.options = $.extend(true, {}, $.fn.sketchable.defaults, data.options, opts);
          postProcess(elem);
        });
      } else { // getter
        return $(this).data(namespace).options;
      }
    },
    /**
     * Retrieve data associated to an existing Sketchable instance.
     * @param {string} [property] - Top-level data property, e.g. "instance", "sketch", "options".
     * @return {*}
     * @memberof $.fn.sketchable
     * @example
     * // Read all the data associated to this instance.
     * var data = $('canvas').sketchable('data');
     * // Quick access to the Sketchable instance.
     * var inst = $('canvas').sketchable('data', 'instance');
     */
    data: function(property) {
      var data = $(this).data(namespace);

      if (property) {
        return data[property];
      } else {
        return data;
      }
    },
    /**
     * Get/Set 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, jQuery instance on set (with the new data attached).
     * @memberof $.fn.sketchable
     * @example
     * // Getter: read associated strokes.
     * var strokes = $('canvas').sketchable('strokes');
     * // Setter: replace associated strokes.
     * $('canvas').sketchable('strokes', [ [arr1], ..., [arrN] ]);
     */
    strokes: function(arr) {
      if (arr) { // setter
        return this.each(function() {
          var elem = $(this), data = elem.data(namespace);
          data.strokes = arr;
          redraw(elem);
        });
      } else { // getter
        var data = $(this).data(namespace);
        return data.strokes;
      }
    },
    /**
     * Allow low-level manipulation of the sketchable canvas.
     * @param {function} callback - Callback function, invoked with 2 arguments: elem (CANVAS element) and data (private element data).
     * @return {object} jQuery
     * @memberof $.fn.sketchable
     * @example
     * $('canvas').sketchable('handler', function(elem, data) {
     *   // do something with elem or data
     * });
     */
    handler: function(callback) {
      return this.each(function() {
        var elem = $(this), data = elem.data(namespace);
        callback(elem, data);
      });
    },
    /**
     * Clears canvas <b>together with</b> associated strokes data.
     * @return {object} jQuery
     * @param {boolean} [keepStrokes] - Preserve stroke data (default: false).
     * @memberof $.fn.sketchable
     * @see $.fn.sketchable.handler
     * @example
     * var $canvas = $('canvas').sketchable();
     * // This will remove strokes data as well.
     * $canvas.clear();
     * // If you only need to clear the canvas, just do:
     * $canvas.clear(true);
     * // Or, alternatively:
     * $canvas.sketchable('handler', function(elem, data) {
     *   data.sketch.clear();
     * });
     */
    clear: function(keepStrokes) {
      return this.each(function() {
        var elem = $(this), data = elem.data(namespace), options = data.options;

        data.sketch.clear();

        if (!keepStrokes) {
          data.strokes = [];
          data.coords  = {};
        }

        if (typeof options.events.clear === 'function')
          options.events.clear(elem, data);
      });
    },
    /**
     * Reinitialize a sketchable canvas with given configuration options.
     * @param {object} [opts] - Configuration (default: {@link $.fn.sketchable.defaults}).
     * @return {object} jQuery
     * @memberof $.fn.sketchable
     * @example
     * var $canvas = $('canvas').sketchable();
     * // Reset default state.
     * $canvas.sketchable('reset');
     * // Reset with custom configuration.
     * $canvas.sketchable('reset', { interactive:false });
     */
    reset: function(opts) {
      return this.each(function() {
        var elem = $(this), data = elem.data(namespace), options = data.options;

        elem.sketchable('destroy').sketchable(opts);

        if (typeof options.events.reset === 'function')
          options.events.reset(elem, data);
      });
    },
    /**
     * Destroy sketchable canvas, together with strokes data and associated events.
     * @return {object} jQuery
     * @memberof $.fn.sketchable
     * @example
     * var $canvas = $('canvas').sketchable();
     * // This will leave the canvas element intact.
     * $canvas.sketchable('destroy');
     */
    destroy: function() {
      return this.each(function() {
        var elem = $(this), data = elem.data(namespace), options = data.options;

        elem.unbind('mouseup', mouseupHandler);
        elem.unbind('mousemove', mousemoveHandler);
        elem.unbind('mousedown', mousedownHandler);
        elem.unbind('touchstart', touchdownHandler);
        elem.unbind('touchmove', touchmoveHandler);
        elem.unbind('touchend', touchupHandler);

        elem.removeData(namespace);

        if (options && typeof options.events.destroy === 'function')
          options.events.destroy(elem, data);
      });
    },
    /**
     * Decorate event. Will execute default event first.
     * @param {string} evName - Event name.
     * @param {function} listener - Custom event listener.
     * @param {string} initiator - Some identifier.
     * @return {object} jQuery
     * @memberof $.fn.sketchable
     * @example
     * // Decorate 'clear' method with `myClearFn()`,
     * // using 'someId' to avoid collisions with other decorators.
     * $('canvas').sketchable('decorate', 'clear', myClearFn, 'someId');
     */
    decorate: function(evName, listener, initiator) {
      return this.each(function() {
        var elem = $(this), data = elem.data(namespace), options = data.options;
        // Flag event override so that it doesn't get fired more than once.
        var overrideId = '_bound$'+ evName + '.' + initiator;
        if (data[overrideId]) return;
        data[overrideId] = true;
        if (options.events && typeof options.events[evName] === 'function') {
          // User has defined this event, so wrap it.
          var fn = options.events[evName];
          options.events[evName] = function() {
            // Exec original function first, then exec our listener.
            fn.apply(this, arguments);
            listener.apply(this, arguments);
          };
        } else {
          // User has not defined this event, so attach our listener.
          options.events[evName] = listener;
        }
      });
    },
  };

  /**
   * Create a <tt>jQuery Sketchable</tt> instance.
   * This is a jQuery wrapper for the <tt>jSketch</tt> drawing class.
   * @namespace $.fn.sketchable
   * @param {string|object} method - Method to invoke, or a configuration object.
   * @return {object} jQuery
   * @version 2.3
   * @author Luis A. Leiva
   * @license MIT license
   * @example
   * $('canvas').sketchable();
   * $('canvas').sketchable({ interactive:false });
   */
  $.fn.sketchable = function(method) {
    var args = Array.prototype.slice.call(arguments, 1);
    if (typeof method === 'object' || !method) {
      // Constructor.
      return api.init.apply(this, arguments);
    } else if (method.indexOf('.') > -1) {
      // Plugin method.
      var actualMethod = locate(api, method);
      return actualMethod.apply(this, args);
    } else if (api[method]) {
      // Instance method.
      return api[method].apply(this, args);
    } else {
      $.error('Unknown method: ' + method);
    }
    return this;
  };

  /**
   * Public API. Provides access to all methods of jQuery Sketchable instances.<br>
   * Note: This is equivalent to accessing `Sketchable.prototype` in the non-jQuery version.
   * @namespace $.fn.sketchable.api
   * @type {object}
   * @see Sketchable
   */
  $.fn.sketchable.api = api;

  /**
   * Plugins store.
   * @namespace $.fn.sketchable.plugins
   * @type {object}
   * @example
   * // All plugins are created after instance initialization:
   * $.fn.sketchable.plugins['your-awesome-plugin'] = function($instance) {
   *   // Do something with the jQuery Sketchable instance.
   * }
   */
  $.fn.sketchable.plugins = {};

  /**
   * Default configuration.
   * Note that `events.mouse*` callbacks are triggered only if <tt>interactive</tt> is set to <tt>true</tt>.
   * @namespace $.fn.sketchable.defaults
   * @type {object}
   * @example
   * // The following is the default configuration:
   * $('canvas').sketchable({
   *   interactive: true,
   *   mouseupMovements: false,
   *   relTimestamps: false,
   *   multitouch: true,
   *   cssCursors: true,
   *   filterCoords: false,
   *   // Event hooks.
   *   events: {
   *     init: function(elem, data) {
   *       // Called when the Sketchable instance is created.
   *     },
   *     destroy: function(elem, data) {
   *       // Called when the Sketchable instance is destroyed.
   *     },
   *     clear: function(elem, data) {
   *       // Called when the canvas is cleared.
   *       // This event includes clearing strokes data, too.
   *     },
   *     mousedown: function(elem, data, evt) {
   *       // Called when the user clicks or taps on the canvas.
   *     },
   *     mousemove: function(elem, data, evt) {
   *       // Called when the user moves the mouse or finger over the canvas.
   *     },
   *     mouseup: function(elem, data, evt) {
   *       // Called when the user lifts the mouse or finger off the canvas.
   *     },
   *   },
   *   // Drawing options, to be used in jSketch lib.
   *   graphics: {
   *     firstPointSize: 3,
   *     lineWidth: 3,
   *     strokeStyle: '#F0F',
   *     fillStyle: '#F0F',
   *     lineCap: 'round',
   *     lineJoin: 'round',
   *     miterLimit: 10
   *   }
   * });
   */
  $.fn.sketchable.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,
    // Indicate 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,
    // Remove duplicated consecutive points; e.g. `(1,2)(1,2)(5,5)(1,2)` becomes `(1,2)(5,5)(1,2)`.
    // This is useful for touchscreens, where the same event is registered more than once.
    filterCoords: false,
    // Event hooks.
    events: {
      // init: function(elem, data) { },
      // clear: function(elem, data) { },
      // destroy: function(elem, data) { },
      // mousedownBefore: function(elem, data, evt) { },
      // mousedown: function(elem, data, evt) { },
      // mousemoveBefore: function(elem, data, evt) { },
      // mousemove: function(elem, data, evt) { },
      // mouseupBefore: function(elem, data, evt) { },
      // mouseup: function(elem, data, evt) { },
    },
    // Drawing options, to be used in jSketch lib.
    graphics: {
      firstPointSize: 3,
      lineWidth: 3,
      strokeStyle: '#F0F',
      fillStyle: '#F0F',
      lineCap: 'round',
      lineJoin: 'round',
      miterLimit: 10,
    },
  };

  /**
   * @private
   */
  function postProcess(elem, options) {
    if (!options) options = elem.data(namespace).options;

    var domEl = elem.get(0);
    if (options.cssCursors) {
      // Visually indicate whether this element is interactive or not.
      domEl.style.cursor = options.interactive ? 'pointer' : 'not-allowed';
    }
    // Fix unwanted highlight "bug".
    domEl.onselectstart = function() {
      return false;
    };
  };

  /**
   * @private
   */
  function locate(obj, path) {
    path = path.split('.');
    for (var i = 0; i < path.length; i++) {
      var key = path[i];
      obj = obj[key];
    }
    return obj;
  }

  /**
   * @private
   */
  function getMousePos(e) {
    var elem = $(e.target), pos = elem.offset();
    return {
      x: Math.round(e.pageX - pos.left),
      y: Math.round(e.pageY - pos.top),
      time: Date.now(),
    };
  };

  /**
   * @private
   */
  function saveMousePos(idx, data, pt) {
    // Current coords are already initialized.
    var coords = data.coords[idx];

    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 && coords.length === 0) data.timestamp = pt.time;
      pt.time -= data.timestamp;
    }

    coords.push([pt.x, pt.y, pt.time, +data.sketch.isDrawing, idx]);

    // Check if consecutive points should be removed.
    if (data.options.filterCoords && coords.length > 1) {
      var lastIndex = coords.length - 1;
      var lastCoord = coords[lastIndex];
      var currCoord = coords[lastIndex - 1];
      if (lastCoord[0] == currCoord[0] && lastCoord[1] == currCoord[1]) {
        coords.splice(lastIndex, 1);
      }
    }
  };

  /**
   * @private
   */
  function mousedownHandler(e) {
    if (e.originalEvent.touches) return false;
    downHandler(e);
  };

  /**
   * @private
   */
  function mousemoveHandler(e) {
    if (e.originalEvent.touches) return false;
    moveHandler(e);
  };

  /**
   * @private
   */
  function mouseupHandler(e) {
    if (e.originalEvent.touches) return false;
    upHandler(e);
  };

  /**
   * @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 (e.which === 3) return false;

    var idx   = Math.abs(e.identifier || 0),
      elem    = $(e.target),
      data    = elem.data(namespace),
      options = data.options;
    // Exit early if interactivity is disabled.
    if (!options.interactive) return;

    var p = getMousePos(e);

    if (typeof options.events.mousedownBefore === 'function')
      options.events.mousedownBefore(elem, data, e);

    // Mark visually 1st point of stroke.
    if (options.graphics.firstPointSize > 0) {
      data.sketch
        .beginFill(options.graphics.fillStyle)
        .fillCircle(p.x, p.y, options.graphics.firstPointSize)
        .endFill();
    }

    data.sketch.isDrawing = true;
    data.sketch.beginPath();

    // Ensure that coords is properly initialized.
    var coords = data.coords[idx];
    if (!coords) coords = [];
    // Don't mix mouseup and mousedown in the same stroke.
    if (coords.length > 0) data.strokes.push(coords);
    // In any case, ensure that coords is  properly reset/initialized.
    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   = Math.abs(e.identifier || 0),
      elem    = $(e.target),
      data    = elem.data(namespace),
      options = data.options;
    // Exit early if interactivity is disabled.
    if (!options.interactive) return;
    // Grab 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 (typeof options.events.mousemoveBefore === 'function')
      options.events.mousemoveBefore(elem, data, e);

    var coords = data.coords[idx];
    var last = coords[coords.length - 1];
    if (last) {
      var lineColor, lineWidth;
      if (data.sketch.isDrawing) {
          // Style for regular, pendown strokes.
          lineColor = options.strokeStyle;
          lineWidth = options.lineWidth;
      } else if (options.mouseupMovements) {
          // Style for penup strokes.
          lineColor = options.mouseupMovements.strokeStyle || '#DDD';
          lineWidth = options.mouseupMovements.lineWidth || 1;
      }
      data.sketch.lineStyle(lineColor, lineWidth)
        .line(last[0], last[1], p.x, p.y)
        .stroke();
    }

    saveMousePos(idx, data, p);

    if (typeof options.events.mousemove === 'function')
      options.events.mousemove(elem, data, e);
  };

  /**
   * @private
   */
  function upHandler(e) {
    var idx   = Math.abs(e.identifier || 0),
      elem    = $(e.target),
      data    = elem.data(namespace),
      options = data.options;
    // Exit early if interactivity is disabled.
    if (!options.interactive) return;

    if (typeof options.events.mouseupBefore === 'function')
      options.events.mouseupBefore(elem, data, e);

    data.sketch.isDrawing = false;
    data.sketch.closePath();

    data.strokes.push(data.coords[idx]);
    data.coords[idx] = [];

    if (typeof options.events.mouseup === 'function')
      options.events.mouseup(elem, data, e);
  };

  /**
   * @private
   */
  function execTouchEvent(e, callback) {
    var elem  = $(e.target),
      data    = elem.data(namespace),
      options = data.options;

    if (options.multitouch) {
      // Track all fingers.
      var touches = e.originalEvent.changedTouches;
      for (var i = 0; i < touches.length; i++) {
        var touch = touches[i];
        callback(touch);
      }
    } else {
      // Track only the current finger.
      var touch = e.originalEvent.touches[0];
      callback(touch);
    }
  };

  /**
   * Redraw canvas according to stored strokes data.
   * @return {object} jQuery
   * @memberof $.fn.sketchable
   * @private
   */
  function redraw(elem) {
    var data  = elem.data(namespace),
      options = data.options,
      sketch  = data.sketch;

    // Clear current canvas content, since strokes content may have changed.
    sketch.clear();

    for (var s = 0; s < data.strokes.length; s++) {
      var stroke = data.strokes[s];
      for (var t = 0; t < stroke.length; t++) {
        var currPt = stroke[t];
        var nextPt = stroke[t + 1];

        // By default, assume all strokes are pendown strokes.
        var isDrawing = true;
        if (currPt.length > 3) {
          isDrawing = currPt[3] === 1;
        }

        // Skip penup strokes, if specified.
        if (!isDrawing && !options.mouseupMovements) {
          break;
        }

        if (t === 0) {
          // Draw first point of stroke.
          if (options.graphics.firstPointSize > 0) {
            sketch.beginFill(options.graphics.fillStyle)
              .fillCircle(currPt[0], currPt[1], options.graphics.firstPointSize)
              .endFill();
          }
          // Set line styles.
          var lc, lw;
          if (isDrawing) {
            // Style for regular, pendown strokes.
            lc = options.strokeStyle;
            lw = options.lineWidth;
          } else if (options.mouseupMovements) {
            // Style for penup strokes.
            lc = options.mouseupMovements.strokeStyle || '#DDD';
            lw = options.mouseupMovements.lineWidth || 1;
          }
          sketch.lineStyle(lc, lw);
        } else if (nextPt) {
          // Connect consecutive points with lines.
          sketch.beginPath()
            .line(currPt[0], currPt[1], nextPt[0], nextPt[1])
            .stroke()
            .closePath();
        }
      }
    }
  }

})(jQuery);