Source: postcard.js

'use strict';

/**
 * Represents a new Postcard instance
 * @constructor
 * @param {HTMLCanvasElement} element - The Canvas element to render.
 * @param {Object} options - Any options to overwrite the defaults.
 */
function Postcard( element, options ) {

  /***** base variables *****/
  var defaults = {
    height: "",
    width: "",
    proxyURL: SERVER_URL + "image_proxy.php",
    filename: "yourpostcard.png",
    renderInterval: "30",
    allowSelections: false,
    backgroundColor: "#ffffff",
    fontFamily: "sans-serif",
    fontSize: "16px",
    fontColor: "#fff",
    fontStyle: "normal",
    fontWeight: "normal"
  },
  browser = { isOpera : false, isFirefox : false, isSafari : false, isChrome : false, isIE : false };

  /***** init *****/
  this.elm = element;
  this.opts = _extend( {}, defaults, options);    
  this.height = this.opts.height = this.elm.height;
  this.width = this.opts.width = this.elm.width;
  this.startingZindex = 1,
  this.ctx = this.elm.getContext("2d");

  /***** browser details *****/
  this.browser = browser;
  this.browser.isOpera = !!(window.opera && window.opera.version);  // Opera 8.0+
  this.browser.isFirefox = _testCSS('MozBoxSizing');                 // FF 0.8+
  this.browser.isSafari = Object.prototype.toString.call(window.HTMLElement).indexOf('Constructor') > 0;
  this.browser.isChrome = !this.browser.isSafari && _testCSS('WebkitTransform');  // Chrome 1+
  this.browser.isIE = /*@cc_on!@*/false || _testCSS('msTransform');  // At least IE6 

  /***** coordinate fixing *****/
  if (document.defaultView && document.defaultView.getComputedStyle) {
    this.stylePaddingLeft = parseInt(document.defaultView.getComputedStyle(this.elm, null)['paddingLeft'], 10)      || 0;
    this.stylePaddingTop  = parseInt(document.defaultView.getComputedStyle(this.elm, null)['paddingTop'], 10)       || 0;
    this.styleBorderLeft  = parseInt(document.defaultView.getComputedStyle(this.elm, null)['borderLeftWidth'], 10)  || 0;
    this.styleBorderTop   = parseInt(document.defaultView.getComputedStyle(this.elm, null)['borderTopWidth'], 10)   || 0;
  }
  var html = document.body.parentNode;
  this.htmlTop = html.offsetTop;
  this.htmlLeft = html.offsetLeft;

  /***** state variables *****/
  this.valid = false;         /* applies only if canvas has been changed in some way, prevents from pointless redrawing */
  this.dragging = false;      /* keeps track of when an element on the canvas is being dragged */
  this.selection = null;      /* current selection - pointer to the PostcardObject */
  this.dragoffx = 0;          /* See mousedown and mousemove events for explanation */
  this.dragoffy = 0;  
  var currentState = this;    /* closure for events and rendering loop */

  /***** set proxy url for image objects and initialize JSONP *****/
  PostcardImageObject.prototype.proxyURL = this.opts.proxyURL;
  JSONP.init({
    error: function(ex){ console.error("Failed to load : " + ex.url); }
  });  

  /***** rendering stack *****/
  this.renderingStack = new OrderedMap();

  /***** main rendering loop *****/
  setInterval(function() { currentState.render(); }, currentState.opts.renderInterval);

  /***** background *****/
  this.background = this.addObject("__background__", 0, { w: this.width, h: this.height, fill: this.opts.backgroundColor });


  /***** events *****/
  this.elm.addEventListener("forcerender", function(e) {
    currentState.triggerRefresh();
  });

  //fixes a problem where double clicking causes text to get selected on the canvas
  this.elm.addEventListener('selectstart', function(e) { e.preventDefault(); return false; }, false);

  this.elm.addEventListener('mousedown', function(e) { 
    currentState.dragging = true;
    if(currentState.opts.allowSelections) selectionMouseDownEvent(e); 
  }, false);
  this.elm.addEventListener('mousemove', function(e) { 
    if(currentState.opts.allowSelections) selectionMouseMoveEvent(e); 
  }, true);
  this.elm.addEventListener('mouseup', function(e) { 
    currentState.dragging = false; 
  }, true);
  // // double click for making new shapes
  // this.elm.addEventListener('dblclick', function(e) {
  //   var mouse = getMouse(e);
  //   //myState.addShape(new Shape(mouse.x - 10, mouse.y - 10, 20, 20, 'rgba(0,255,0,.6)'));
  // }, true);



  /***** private methods *****/
  /**
   * Gets accurate mouse coordinates
   * @private
   * @param {Event} e - the click event
   */ 
  var getMouse = function(e) {
    var element = currentState.elm, offsetX = 0, offsetY = 0, mx, my;
    
    // Compute the total offset
    if (element.offsetParent !== undefined) {
      do {
        offsetX += element.offsetLeft;
        offsetY += element.offsetTop;
      } while ((element = element.offsetParent));
    }

    // Add padding and border style widths to offset
    // Also add the <html> offsets in case there's a position:fixed bar
    offsetX += currentState.stylePaddingLeft + currentState.styleBorderLeft + currentState.htmlLeft;
    offsetY += currentState.stylePaddingTop + currentState.styleBorderTop + currentState.htmlTop;

    mx = e.pageX - offsetX;
    my = e.pageY - offsetY;
    
    // We return a simple javascript object (a hash) with x and y defined
    return {x: mx, y: my};
  };
  /**
   * Mouse down event to use if selection is enabled
   * @private
   * @param {Event} e - the click event
   */
  var selectionMouseDownEvent = function(e) {
    var mouse = getMouse(e);
    var mx = mouse.x;
    var my = mouse.y;
    var topZindex = 0;        /* keeps track of the highest z-index found so far */
    var tempSelection = null;

    currentState.renderingStack.forEach(function(key, zindex, object) {
      /* if a hit is detected and its at the top (so far) */
      if(object.contains(mx,my) && zindex >= topZindex) { 
        tempSelection = object;
      }      
    });

    if(tempSelection) {
      currentState.dragoffx = mouse.x - tempSelection.x;
      currentState.dragoffy = mouse.y - tempSelection.y;      
      currentState.selection = tempSelection;
      currentState.valid = false;
      return;
    }

    /* havent returned means we have failed to select anything.
     * If there was an object selected, we deselect it
     */
    if (currentState.selection) {
      currentState.selection = null;
      currentState.valid = false;       /* Need to clear the old selection border */
    }    
  };
  /**
   * Mouse move event to use if selection is enabled
   * @private
   * @param {Event} e - the click event
   */  
  var selectionMouseMoveEvent = function(e) {
    if (currentState.dragging) {
      var mouse = getMouse(e);
      // We don't want to drag the object by its top-left corner, we want to drag it
      // from where we clicked. Thats why we saved the offset and use it here
      currentState.selection.x = mouse.x - currentState.dragoffx;
      currentState.selection.y = mouse.y - currentState.dragoffy;   
      currentState.valid = false; // Something's dragging so we must redraw
    }    
  };

  /***** privileged methods *****/
  /** 
   * Creates a wrapper function then stores a reference to it so we can clear it later
   * @param {Function} userFunc - User defined function with x,y as params
   */
  this.onMouseDown = function(userFunc) { 
    this.userMouseDown = function(e) { 
      var mouse = getMouse(e);
      userFunc(mouse.x, mouse.y); 
    };
    this.elm.addEventListener('mousedown', this.userMouseDown, false);
  };
  /** 
   * Creates a wrapper function then stores a reference to it so we can clear it later  
   * @param {Function} userFunc - User defined function with x,y as params
   */
  this.onMouseMove = function(userFunc) { 
    this.userMouseMove = function(e) { 
      var mouse = getMouse(e);
      userFunc(mouse.x, mouse.y); 
    };
    this.elm.addEventListener('mousemove', this.userMouseMove, true);
  };  
  /** 
   * Creates a wrapper function then stores a reference to it so we can clear it later    
   * @param {Function} userFunc - User defined function with x,y as params
   */
  this.onMouseUp = function(userFunc) { 
    this.userMouseUp = function(e) { 
      var mouse = getMouse(e);
      userFunc(mouse.x, mouse.y); 
    };
    this.elm.addEventListener('mouseup', this.userMouseUp, true);
  }; 
  /** 
   * Clear *user-defined* mouse events
   */  
  this.clearMouseEvents = function() {
    if(this.userMouseDown) this.elm.removeEventListener('mousedown', this.userMouseDown, false);
    if(this.userMouseMove) this.elm.removeEventListener('mousemove', this.userMouseMove, true);
    if(this.userMouseUp) this.elm.removeEventListener('mouseup', this.userMouseUp, true);
  };


}; /* end of Postcard() */

/***** public methods *****/
/**
 * Clears the canvas before rerendering.
 */
Postcard.prototype.clear = function() {
  this.ctx.clearRect(0, 0, this.width, this.height);
};  
/**
 * Triggers a refresh of the postcard. Use after changing the properties of any element.
 */
Postcard.prototype.triggerRefresh = function() {
  this.valid = false;
};  
/**
 * Main rendering loop for the Postcard
 */
Postcard.prototype.render = function() {
  if(!this.valid) { 

    this.clear();

    // this should iterate through the rendering stack
    this.renderingStack.forEach(function (key, zindex, object) {
      //console.log("[" + key + "] at: " + zindex);
      object.draw();
    });

    // draw selection
    // right now this is just a stroke along the edge of the selected Shape
    if(this.selection != null) {
      this.ctx.strokeStyle = '#CC0000';
      this.ctx.lineWidth = '2';
      var s = this.selection;
      if(s.type == "text") this.ctx.strokeRect(s.x, s.y-s.h, s.w, s.h);
      else this.ctx.strokeRect(s.x, s.y, s.w, s.h);
    }

    this.valid = true;
  }
  };
/**
* Get an object on the postcard with its ID
* @param {String} id - Unique identifier for the object
* @returns {PostcardObject|Error} - The object in question or an Error if not found
*/
Postcard.prototype.get = function(id) {
  return this.renderingStack.get(id);
};
/**
* Get an object on the postcard with its ID and remove it
* @param {String} id - Unique identifier for the object
*/
Postcard.prototype.remove = function(id) {
  return this.renderingStack.remove(id);
};
/**
* Get multiple objects on the postcard using a test function
* @param {Function} callback - Tests each element given arguments (key, zindex, value). Returning true keeps the element.
* @returns {PostcardObjectArray} - Array of elements which pass the test
*/
Postcard.prototype.getSome = function(callback) {
  return this.renderingStack.filter(callback);
};
/**
* Get the current selection on the postcard, if there is one
* @returns {PostcardObject|null} - The object in question or null
*/
Postcard.prototype.getSelection = function() {
  return this.selection;
};
/**
 * Add a generic object (shape)
 * @param {String} id - Unique identifier for the object
 * @param {Number} [zindex] - Z-index for the object. Defaults to 0
 * @param {Object} [options] - Defines placement and content. See PostcardObject contructor
 * @returns {PostcardObject} Reference to the newly created object 
 */
Postcard.prototype.addObject = function(id, zindex, options) {

  var _id = id, 
      _zindex = this.startingZindex, 
      _options = {};

  if(arguments.length > 2) {
    _zindex = arguments[1];
    _options = arguments[2];
  } else if(arguments.length == 2) {
    if (typeof arguments[1] == "number") _zindex = arguments[1];
    else _options = arguments[1];
  } 

  //console.log("adding: " + _id + " at z-index: " + _zindex + " with options: ", _options);

  var newObject = new PostcardObject("shape", this.ctx, _options);
  this.renderingStack.add(_id, _zindex, newObject);
  return newObject;
};
/**
 * Add an image
 * @param {String} id - Unique identifier for the object
 * @param {Srting} url - URL to the image
 * @param {Number} [zindex] - Z-index for the object. Defaults to 0
 * @param {Object} [options] - Defines placement and content. See PostcardObject contructor
 * @returns {PostcardImageObject} Reference to the newly created object 
 */
Postcard.prototype.addImage = function(id, url, zindex, options) {

  var _id = id, 
      _url = url,
      _zindex = this.startingZindex, 
      _options = {};

  if(typeof arguments[1] !== "string") {
    throw new Error("no url specified? id: " + _id);
  }

  // this is convoluted. should be done better.
  if(arguments.length > 3) {
    _zindex = arguments[2];
    _options = arguments[3];
  } else if(arguments.length === 3) {
    if (typeof arguments[2] == "number") _zindex = arguments[2];
    else _options = arguments[2];
  } else {
    // less than 3
  }

  //console.log("adding: " + _id + " at z-index: " + _zindex + " with options: ", _options);

  var newImageObject = new PostcardImageObject(_url, this.ctx, _options);
  this.renderingStack.add(_id, _zindex, newImageObject);
  return newImageObject;  
};
/**
 * Add an image
 * @param {String} id - Unique identifier for the object
 * @param {Srting} url - URL to the image
 * @param {Number} [zindex] - Z-index for the object. Defaults to 0
 * @param {Object} [options] - Defines placement and content. See PostcardObject contructor
 * @returns {PostcardTextObject} Reference to the newly created object
 */
Postcard.prototype.addText = function(id, text, zindex, options) {

  var _id = id, 
      _text = text,
      _zindex = this.startingZindex, 
      _options = {                        // postcard defaults
        family: this.opts.fontFamily,
        size: this.opts.fontSize,
        fill: this.opts.fontColor,
        style: this.opts.fontStyle,
        weight: this.opts.fontWeight
      };

  if(typeof arguments[1] !== "string") {
    throw new Error("no text specified? id: " + _id);
  }

  // this is convoluted. should be done better.
  if(arguments.length > 3) {
    _zindex = arguments[2];
    _options = _extend( {}, _options, arguments[3]); 
  } else if(arguments.length === 3) {
    if (typeof arguments[2] == "number") _zindex = arguments[2];
    else _options = _options = _extend( {}, _options, arguments[2]); 
  } else {
    // less than 3
  }

  //console.log("adding: " + _id + " at z-index: " + _zindex + " with options: ", _options);

  var newTextObject = new PostcardTextObject(_text, this.ctx, _options);
  this.renderingStack.add(_id, _zindex, newTextObject);
  return newTextObject;
};
/** 
 * Export the Postcard and trigger a save prompt
 * @param {Event} event - The click event
 */
Postcard.prototype.save = function(event) { 

  // var data = this.element.toDataURL("image/png");
  // data = data.substr(data.indexOf(',') + 1).toString();
  // var dataInput = document.createElement("input") ;
  // dataInput.setAttribute("name", 'imgdata') ;
  // dataInput.setAttribute("value", data);

  // var nameInput = document.createElement("input") ;
  // nameInput.setAttribute("name", 'name') ;
  // nameInput.setAttribute("value",this.options.filename + '.png');

  // var myForm = document.createElement("form");
  // myForm.method = 'post';
  // myForm.action = this.options.saveURL;
  // myForm.appendChild(dataInput);
  // myForm.appendChild(nameInput);

  // document.body.appendChild(myForm);
  // myForm.submit();
  // document.body.removeChild(myForm);

  //browsers that don't support the download attribute
  //best we can do is give them a nice message
  if(this.browser.isSafari || this.browser.isIE) {
    alert('opening a new tab, just gotta [right click > save] the image. not ideal.');
    event.target.setAttribute('target', '_blank');
  }
  event.target.href = this.elm.toDataURL();
  event.target.download = this.opts.filename;
};
/** 
 * Export the Postcard and get the Base64 data
 * @returns {dataURI}
 */
Postcard.prototype.export = function() { 
  return this.elm.toDataURL();
}