Source: postcardObjects.js

'use strict';

/**
 * Represents an internal object within the Postcard. Can be selected, redrawn, modified.
 * @constructor
 * @param {text|image|shape} type - The type of object being rendered
 * @param {CanvasRenderingContext2D} ctx - context in which to draw the object
 * @param {Object} [options] - Defines placement and content. 
 * @param {Object} [options.x=0] - X value of the object.
 * @param {Object} [options.y=0] - Y value of the object.
 * @param {Object} [options.w=50] - Width of the object.
 * @param {Object} [options.h=50] - Height of the object.
 * @param {Object} [options.fill=steelblue] - Fill (color) of the object.
 * @param {Object} [options.rotation=0] - Rotation of the object (in degrees).
 */
function PostcardObject(type, ctx, options) {

  var defaults = {
    x: 0,
    y: 0,
    w: 50,
    h: 50,
    opacity: 1.0,
    fill: "steelblue",
    rotation: 0
  };

  this.ctx = ctx;
  this.type = type;
  this.opts = _extend( {}, defaults, options);  
  this.x = parseInt(this.opts.x, 10);                           /* for brevitys sake */
  this.y = parseInt(this.opts.y, 10);
  this.w = parseInt(this.opts.w, 10);
  this.h = parseInt(this.opts.h, 10);
};

/**
 * Generic draw method for PostcardObject.
 */
PostcardObject.prototype.draw = function() {

  // generic
  this.ctx.save();
  this.ctx.fillStyle = this.opts.fill;
  this.globalAlpha = this.opts.opacity;
  this.ctx.fillRect(this.x, this.y, this.w, this.h);
  this.ctx.restore();
};
/**
 * Generic contains method for PostcardObject.
 * @returns {Boolean} if the coordinate is within the object's bounds
 */
PostcardObject.prototype.contains = function(mx, my) {
  // All we have to do is make sure the Mouse X,Y fall in the area between
  // the shape's X and (X + Width) and its Y and (Y + Height)
  return  (this.x <= mx) && (this.x + this.w >= mx) &&
          (this.y <= my) && (this.y + this.h >= my);
};
/**
 * Generic update method for PostcardObject.
 * @param {Object} newOptions - any options to update
 */
PostcardObject.prototype.update = function(newOptions) {
  this.opts = _extend( {}, this.opts, newOptions);  
  this.x = parseInt(this.opts.x, 10);                           /* for brevitys sake */
  this.y = parseInt(this.opts.y, 10);
  this.w = parseInt(this.opts.w, 10);
  this.h = parseInt(this.opts.h, 10);

  // trigger a render of the postcard canvas  
  _triggerEvent(this.ctx.canvas, "forcerender"); 

};
/**
 * Set opacity on the PostcardObject to 1.0
 */
PostcardObject.prototype.show = function() {
  this.opts.opacity = 1.0; 
  _triggerEvent(this.ctx.canvas, "forcerender");   
};
/**
 * Set opacity on the PostcardObject to 0.0
 */
PostcardObject.prototype.hide = function() {
  this.opts.opacity = 0.0;  
  _triggerEvent(this.ctx.canvas, "forcerender");   
};

PostcardImageObject.prototype = new PostcardObject();
PostcardImageObject.prototype.constructor = PostcardImageObject;
/**
 * Represents an internal object that deals with images
 * @constructor
 * @extends PostcardObject
 * @param {String} url - URL to the image (relative or absolute)
 * @param {CanvasRenderingContext2D} ctx - context in which to draw the object 
 * @param {Object} [options] - Defines placement and content.  
 * @param {Object} [options.keepOriginal=true] - Maintains a copy of the image so it can be reverted. 
 * @param {Object} [options.crop=false] - Will crop the image.
 * @param {Object} [options.cropX=0] - Crop X value 
 * @param {Object} [options.cropY=0] - Crop Y value 
 * @param {Object} [options.cropW=0] - Crop width value. 
 * @param {Object} [options.cropH=0] - Crop height value. 
 */
function PostcardImageObject(url, ctx, options) {

  var imageDefaults = {
    keepOriginal: true,
    crop: false,
    cropX: 0,
    cropY: 0,
    cropW: 0,
    cropH: 0
  };
  this.opts = _extend( {}, imageDefaults, options); 
  PostcardObject.apply(this, ["image", ctx, this.opts]);
  this.imageloaded = false;     // used to know when to start drawing
  this.userImageLoaded = function() {};
  this.cache = {};              // cache images so we don't have to keep proxy-ing them

  /***** internal canvas for ImageData manipulation *****/
  this._canvas = document.createElement('canvas');
  this._ctx = this._canvas.getContext('2d');

  var curr = this;

  /* initialize with the supplied url */
  if(url.length) loadImage(url);
  //else throw new Error("empty URL string");
  //will init to empty object instead

  /***** private methods *****/
  /**
   * Method to get image data via proxy. Uses the JSONP library to get the raw image data, then
   * adds it to the cache of images and makes a call to applyImage()
   * @private
   * @params {String} url - The URL of the image to load.
   */
  function loadImage(url) {

    var regex_url_test = /(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?/;

    if(!url) throw new Error('no URL specified');

    if(url.indexOf('http://') === -1 && url.indexOf('https://') === -1) {   /* relative url */
      curr.url = url;
      applyImage(url);
    } else {                                                                /* absolute url */
      var isSecure = location.protocol === "https:";
      var proxyURL = curr.proxyURL + "?callback=?";

      // If url specified and is a url + if server is secure when image or user page is
      if(proxyURL && regex_url_test.test(proxyURL) && !(isSecure && proxyURL.indexOf('http:') == 0)) {
        // do nothing, eventually do something?
      } else throw new Error('bad or insecure server url combination.');

      JSONP.get( proxyURL, { url: escape(url) }, function(rawData) {

        /* now that we know the URL is good, we'll set it to the object
         * we add it to the cache for later, then pass the new cached object to applyImage
         */
        curr.url = url;
        curr.cache[curr.url] = rawData.data;         
        applyImage(curr.cache[curr.url]);
      });
    }
  };
  /**
   * Takes raw image data (from the proxy or cache) and sets it to the ImageObject,
   * using an HTMLImageElement as a shell
   * @private
   * @params {Object} newImg - Contains width, height, and src of DataURI
   */
  function applyImage(newImg) {

      //console.log('applying image: ', newImg);

      var newImgElm = new Image();
      newImgElm.onload = function () {

        curr._canvas.width = this.width;
        curr._canvas.height = this.height;
        curr.imgElm = this;

        // draws the image on the internal canvas so we can extract the ImageData from it 
        curr._ctx.drawImage(curr.imgElm, 0, 0, curr._canvas.width, curr._canvas.height); 

        // makes a copy of the ImageData object for reverting
        if(curr.opts.keepOriginal) {
          curr.origImgData = curr._ctx.getImageData(0, 0, curr._canvas.width, curr._canvas.height);
        }

        curr.imageloaded = true;
        curr.userImageLoaded.apply(curr);

        // trigger a render of the postcard canvas  
        _triggerEvent(curr.ctx.canvas, "forcerender"); 

        // ensure that this only occurs at first load, and not for subsequent image.src changes
        this.onload = onImageRefresh;  
        
      };
      newImgElm.src = newImg;
  };

  /**
   * Happens whenever image.src changes
   * @private
   */
  function onImageRefresh() {
    _triggerEvent(curr.ctx.canvas, "forcerender"); 
  };

  /***** privileged methods *****/
  /**
   * Change the source URL of the ImageObject
   * @param {String} newUrl - the new URL 
   */
  this.changeURL = function(newUrl) {
    if(newUrl === this.url) {
      this.userImageLoaded.apply(this);            // still provide user callback function
      return;
    }
    this.imageloaded = false;
    if(newUrl in this.cache) {
      this.url = newUrl;
      applyImage(this.cache[newUrl]);
    }
    else loadImage(newUrl);
  }
};
/***** public methods *****/
/**
 * Draw method for the image object. Only applies after image data is loaded via proxy
 */
PostcardImageObject.prototype.draw = function() {
  if(this.imageloaded) {
    var x = this.x, y = this.y;
    this.ctx.save();
    if(this.opts.rotation > 0) {
      this.ctx.translate(this.x + (this.w/2), this.y + (this.h/2)); 
      this.ctx.rotate(this.opts.rotation*Math.PI/180);
      x = -this.w/2; y = -this.h/2;
    }
    this.ctx.globalAlpha = this.opts.opacity;
    if(this.opts.crop) {
      this.ctx.drawImage(this.imgElm, cropX, cropY, cropW, cropH, x, y, this.w, this.h);
    } else {
      this.ctx.drawImage(this.imgElm, x, y, this.w, this.h);      
    }
    this.ctx.restore();
  }
};
/**
 * Revert any changes made to the image
 */
PostcardImageObject.prototype.revert = function() {
  if(!this.opts.keepOriginal) throw new Error("original image wasn't preserved");
  this._ctx.putImageData(this.origImgData, 0, 0);
  this.imgElm.src = this._canvas.toDataURL();
};
/**
 * Get the original image data
 * @returns {ImageData|Error} 
 */
PostcardImageObject.prototype.getOriginalImageData = function() {
  if(this.imageloaded && this.opts.keepOriginal) {
    var origImgData = this._ctx.createImageData(this.origImgData.width, this.origImgData.height);
    origImgData.data.set(this.origImgData.data);
    return origImgData;
  }
  else throw new Error("image not loaded/not saving original");
};
/**
 * Get the current image data
 * @returns {ImageData|Error} 
 */
PostcardImageObject.prototype.getCurrentImageData = function() {
  if(this.imageloaded) return this._ctx.getImageData(0, 0, this._canvas.width, this._canvas.height);
  else throw new Error("image not loaded");
};
/**
 * Set the new image data
 * @param {ImageData} newImageData - new ImageData object to set
 */
PostcardImageObject.prototype.setImageData = function(newImageData) {
  this._ctx.putImageData(newImageData, 0, 0);
  this.imgElm.src = this._canvas.toDataURL();
};
/**
 * Get rotation of the image object.
 */
PostcardImageObject.prototype.getRotation = function() {
  return this.opts.rotation;
};
/**
 * Set rotation of the image object.
 * @param {Number} angle - Angle of rotation, in degrees;
 */
PostcardImageObject.prototype.setRotation = function(angle) {
  this.opts.rotation = angle % 360;
  _triggerEvent(this.ctx.canvas, "forcerender");
};
/**
 * Set a callback function to be called when an image is done loading
 * @param {Function} callback - Callback function. Applied to `this`
 */
PostcardImageObject.prototype.onImageLoaded = function(callback) {
  this.userImageLoaded = callback;
};


PostcardTextObject.prototype = new PostcardObject();
PostcardTextObject.prototype.constructor = PostcardTextObject;
/**
 * Represents an internal object that deals with images
 * @constructor
 * @extends PostcardObject
 * @param {String} text - actual text value
 * @param {CanvasRenderingContext2D} ctx - context in which to draw the object 
 * @param {Object} [options] - Defines placement and content.  
 */
function PostcardTextObject(text, ctx, options) {

  var textDefaults = {
    fill: "#ffffff",
    style: "normal",
    weight: "normal",
    size: "16px",
    family: "sans-serif",
    fontString: "",
    centered: false,
  };
  this.opts = _extend( {}, textDefaults, options); 
  PostcardObject.apply(this, ["text", ctx, this.opts]);

  this.text = text;
  var curr = this;

  /***** internal canvas for manipulation *****/
  this._canvas = document.createElement('canvas');
  this._ctx = this._canvas.getContext('2d');

  var measureText = function() {
    curr._ctx.font = curr.getFont();
    curr.w = curr._ctx.measureText(curr.text).width;    
    curr.h = parseInt(curr.opts.size, 10);    
  };

  //console.log("text: " + this.text + " w: " + this.w + " h: " + this.h);
  if(this.opts.fontString.length) this.setFont(this.opts.fontString);
  else measureText();
};
/**
 * Draw method for the text object
 */
PostcardTextObject.prototype.draw = function() {

  this.ctx.save();             
  this.ctx.fillStyle = this.opts.fill; 
  this.ctx.font = this.getFont(); 
  this.ctx.fillText(this.text, this.x, this.y);
  this.ctx.restore();
};
/**
 * Generic update method for PostcardObject.
 * @param {Object} newOptions - any options to update
 */
PostcardTextObject.prototype.update = function(newOptions) {
  this.opts = _extend( {}, this.opts, newOptions);  
  this.x = parseInt(this.opts.x, 10);                           /* for brevitys sake */
  this.y = parseInt(this.opts.y, 10);
  this.w = this.opts.w = this._ctx.measureText(this.text).width; 
  this.h = parseInt(this.opts.h, 10);
};
/**
 * Contains method for ImageObject. Necessary because y starts at the bottom left
 * @returns {Boolean} if the coordinate is within the object's bounds
 */
PostcardTextObject.prototype.contains = function(mx, my) {
  // All we have to do is make sure the Mouse X,Y fall in the area between
  // the shape's X and (X + Width) and its Y and (Y + Height)
  return  (this.x <= mx) && (this.x + this.w >= mx) &&
          (this.y >= my) && (this.y - this.h <= my);
          //(this.y >= my) && (this.y + this.h <= my);
};
/**
 * Update the TextObject with a new text value
 * @param {String} newText - the new text
 */
PostcardTextObject.prototype.changeText = function(newText) {
  this.text = newText;
  this.w = this.opts.w = this._ctx.measureText(this.text).width;
  _triggerEvent(this.ctx.canvas, "forcerender");
};
/**
 * Formats the font options into the string thats recognized by ctx.font
 * Format: "style weight size family"
 */
PostcardTextObject.prototype.getFont = function() {
  return this.opts.style + ' ' 
          + 'normal '
          + this.opts.weight + ' ' 
          + this.opts.size + ' '
          + this.opts.family;
};
/**
 * Allows for styling to be set via string, a la CSS
 * NOTE: relies on "px" being part of the font size declaration
 * Format: "hex style weight size family"
 */
PostcardTextObject.prototype.setFont = function (fontString) {

    //format : "hex style weight size family"
    //relies on "px" being part of the font size declaration

    var pieces = fontString.split(' ', 4),
        family = fontString.slice(fontString.indexOf('px') + 3);
    this.opts.fill = pieces[0];
    this.opts.style = pieces[1];
    this.opts.weight = pieces[2];
    this.opts.size = pieces[3];
    this.opts.family = family;
    this._ctx.font = this.getFont();
    this.w = this.opts.w = this._ctx.measureText(this.text).width;    
    this.h = this.opts.h = parseInt(this.opts.size, 10); 
    _triggerEvent(this.ctx.canvas, "forcerender");
};