graphics/webimage.js

'use strict';

var Thing = require('./thing.js');

var UNDEFINED = -1;
var NOT_LOADED = 0;

var NUM_CHANNELS = 4;
var RED = 0;
var GREEN = 1;
var BLUE = 2;
var ALPHA = 3;

// Keep track of cross origin WebImage URLs that have already been loaded
// so we can take advantage of loading cross origin images from the browser cache
var cachedCrossOriginURLs = {};

/**
 * @constructor
 * @augments Thing
 * @param {string} filename - Filepath to the image
 */
function WebImage(filename) {
    if (typeof filename !== 'string') {
        throw new TypeError(
            'You must pass a string to <span class="code">' +
                "new WebImage(filename)</span> that has the image's URL."
        );
    }
    Thing.call(this);
    var self = this;

    this.image = new Image();
    // If the image is from a different origin, we need to request the image using
    // crossOrigin 'Anonymous', which allows WebImage to treat the image as
    // same origin and manipulate pixel data
    // https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/crossorigin
    var urlParser = document.createElement('a');
    urlParser.href = filename;
    var src = filename;
    if (urlParser.origin != window.location.origin) {
        this.image.crossOrigin = 'Anonymous';

        // If we've loaded this cross origin URL before, keep using the same URL
        if (cachedCrossOriginURLs.hasOwnProperty(filename)) {
            src = cachedCrossOriginURLs[filename];
        } else {
            // Otherwise we need to avoid the browser cache
            // Browser may have the image cached without the proper
            // Access-Control-Allow-Origin header on the resource
            // Ensure that we initiate a new crossOrigin 'anonymous' request for this
            // image, rather than pulling from browser cache, by making filename unique
            // We'll keep using this unique filename next time
            src = filename + '?time=' + Date.now();
            cachedCrossOriginURLs[filename] = src;
        }
    }
    this.imageLoaded = false;
    this.image.src = src;
    this.filename = filename;
    this.width = NOT_LOADED;
    this.height = NOT_LOADED;
    this.image.onload = function() {
        self.imageLoaded = true;
        self.checkDimensions();
        self.loadPixelData();
        if (self.loadfn) {
            self.loadfn();
        }
    };
    this.set = 0;
    this.type = 'WebImage';

    this.displayFromData = false;
    this.dirtyHiddenCanvas = false;
    this.data = NOT_LOADED;
}

WebImage.prototype = new Thing();
WebImage.prototype.constructor = WebImage;

/**
 * Set a function to be called when the WebImage is loaded.
 *
 * @param {function} callback - A function
 */
WebImage.prototype.loaded = function(callback) {
    this.loadfn = callback;
};

/**
 * Set the image of the WebImage.
 *
 * @param {string} filename - Filepath to the image
 */
WebImage.prototype.setImage = function(filename) {
    if (typeof filename !== 'string') {
        throw new TypeError(
            'You must pass a string to <span class="code">' +
                "new WebImage(filename)</span> that has the image's URL."
        );
    }
    var self = this;

    this.image = new Image();
    // If the image is from a different origin, we need to request the image using
    // crossOrigin 'Anonymous', which allows WebImage to treat the image as
    // same origin and manipulate pixel data
    // https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/crossorigin
    var urlParser = document.createElement('a');
    urlParser.href = filename;
    var src = filename;
    if (urlParser.origin != window.location.origin) {
        this.image.crossOrigin = 'Anonymous';

        // If we've loaded this cross origin URL before, keep using the same URL
        if (cachedCrossOriginURLs.hasOwnProperty(filename)) {
            src = cachedCrossOriginURLs[filename];
        } else {
            // Otherwise we need to avoid the browser cache
            // Browser may have the image cached without the proper
            // Access-Control-Allow-Origin header on the resource
            // Ensure that we initiate a new crossOrigin 'anonymous' request for this
            // image, rather than pulling from browser cache, by making filename unique
            // We'll keep using this unique filename next time
            src = filename + '?time=' + Date.now();
            cachedCrossOriginURLs[filename] = src;
        }
    }
    this.imageLoaded = false;
    this.image.src = src;
    this.filename = filename;
    this.width = NOT_LOADED;
    this.height = NOT_LOADED;
    this.image.onload = function() {
        self.imageLoaded = true;
        self.checkDimensions();
        self.loadPixelData();
        if (self.loadfn) {
            self.loadfn();
        }
    };
    this.set = 0;

    this.displayFromData = false;
    this.dirtyHiddenCanvas = false;
    this.data = NOT_LOADED;
};

/**
 * Reinforce the dimensions of the WebImage based on the image it displays.
 */
WebImage.prototype.checkDimensions = function() {
    if (this.width == NOT_LOADED && this.imageLoaded) {
        this.width = this.image.width;
        this.height = this.image.height;
    }
};

/**
 * Draws the WebImage in the canvas.
 *
 * @param {CodeHSGraphics} __graphics__ - Instance of the __graphics__ module.
 */
WebImage.prototype.draw = function(__graphics__) {
    this.checkDimensions();
    var context = __graphics__.getContext('2d');

    // Scale and translate
    // X scale, X scew, Y scew, Y scale, X position, Y position
    context.setTransform(1, 0, 0, 1, this.x + this.width / 2, this.y + this.height / 2);
    context.rotate(this.rotation);

    // If we should be displaying the underlying pixel data, display that
    // Otherwise display the image
    var elemToDraw = this.image;
    if (this.displayFromData && this.data !== NOT_LOADED && this.hiddenCanvas) {
        // Update the in memory canvas with the latest pixel data if necessary
        if (this.dirtyHiddenCanvas) {
            var ctx = this.hiddenCanvas.getContext('2d');
            ctx.clearRect(0, 0, this.hiddenCanvas.width, this.hiddenCanvas.height);
            ctx.putImageData(this.data, 0, 0);
            this.dirtyHiddenCanvas = false;
        }
        elemToDraw = this.hiddenCanvas;
    }

    try {
        context.drawImage(elemToDraw, -this.width / 2, -this.height / 2, this.width, this.height);
    } catch (err) {
        throw new TypeError(
            'Unable to create a WebImage from <span class="code">' +
                this.filename +
                '</span> ' +
                'Make sure you have a valid image URL. ' +
                'Hint: You can use More > Upload to upload your image and create a valid image URL.'
        );
    } finally {
        // Reset transformation matrix
        // X scale, X scew, Y scew, Y scale, X position, Y position
        context.setTransform(1, 0, 0, 1, 0, 0);
    }
};

/**
 * Return the underlying ImageData for this image.
 * Read more at https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/getImageData
 */
WebImage.prototype.loadPixelData = function() {
    if (this.data === NOT_LOADED && this.imageLoaded) {
        try {
            // get the ImageData for this image
            this.hiddenCanvas = document.createElement('canvas');
            this.hiddenCanvas.width = this.width;
            this.hiddenCanvas.height = this.height;
            var ctx = this.hiddenCanvas.getContext('2d');
            ctx.drawImage(this.image, 0, 0, this.width, this.height);
            this.data = ctx.getImageData(0, 0, this.width, this.height);
            this.dirtyHiddenCanvas = false;
        } catch (err) {
            // NOTE: This should never happen now that we request images using
            // image.crossOrigin = 'Anonymous'
            // If the image was loaded, that means the external domain gave us CORS
            // access to the image and the browser will treat it as if it is same origin,
            // meaning we should be allowed to call 'getImageData'
            //
            // Just in case 'getImageData' fails,
            // Fail silently so we can still display the image from cross origin,
            // we just don't access the underlying image data
            this.data = NOT_LOADED;
        }
    }
    return this.data;
};

/**
 * Checks if the passed point is contained in the WebImage.
 *
 * @param {number} x - The x coordinate of the point being tested.
 * @param {number} y - The y coordinate of the point being tested.
 * @returns {boolean} Whether the passed point is contained in the WebImage.
 */
WebImage.prototype.containsPoint = function(x, y) {
    return x >= this.x && x <= this.x + this.width && y >= this.y && y <= this.y + this.height;
};

/**
 * Gets the width of the WebImage.
 *
 * @returns {number} Width of the WebImage.
 */
WebImage.prototype.getWidth = function() {
    return this.width;
};

/**
 * Gets the height of the WebImage.
 *
 * @returns {number} Height of the WebImage.
 */
WebImage.prototype.getHeight = function() {
    return this.height;
};

/**
 * Sets the size of the WebImage.
 *
 * @param {number} width - The desired width of the resulting WebImage.
 * @param {number} height - The desired height of the resulting WebImage.
 */
WebImage.prototype.setSize = function(width, height) {
    if (arguments.length !== 2) {
        throw new Error(
            'You should pass exactly 2 arguments to <span ' +
                'class="code">setSize(width, height)</span>'
        );
    }
    if (typeof width !== 'number' || !isFinite(width)) {
        throw new TypeError(
            'Invalid value for <span class="code">width' +
                '</span>. Make sure you are passing finite numbers to <span ' +
                'class="code">setSize(width, height)</span>. Did you ' +
                'forget the parentheses in <span class="code">getWidth()</span> ' +
                'or <span class="code">getHeight()</span>? Or did you perform a ' +
                'calculation on a variable that is not a number?'
        );
    }
    if (typeof height !== 'number' || !isFinite(height)) {
        throw new TypeError(
            'Invalid value for <span class="code">height' +
                '</span>. Make sure you are passing finite numbers to <span ' +
                'class="code">setSize(width, height)</span>. Did you ' +
                'forget the parentheses in <span class="code">getWidth()</span> ' +
                'or <span class="code">getHeight()</span>? Or did you perform a ' +
                'calculation on a variable that is not a number?'
        );
    }
    this.width = Math.max(0, width);
    this.height = Math.max(0, height);
};

/* Get and set pixel functions */

/**
 * Gets a pixel at the given x and y coordinates.
 * Read more here:
 * https://developer.mozilla.org/en-US/docs/Web/API/ImageData/data
 *
 * @param {number} x - The x coordinate of the point being tested.
 * @param {number} y - The y coordinate of the point being tested.
 * @returns {array} An array of 4 numbers representing the (r,g,b,a) values
 *                     of the pixel at that coordinate.
 */
WebImage.prototype.getPixel = function(x, y) {
    if (this.data === NOT_LOADED || x > this.width || x < 0 || y > this.height || y < 0) {
        var noPixel = [UNDEFINED, UNDEFINED, UNDEFINED, UNDEFINED];
        return noPixel;
    } else {
        var index = NUM_CHANNELS * (y * this.width + x);
        var pixel = [
            this.data.data[index + RED],
            this.data.data[index + GREEN],
            this.data.data[index + BLUE],
            this.data.data[index + ALPHA],
        ];
        return pixel;
    }
};

/**
 * Get the red value at a given location in the image.
 *
 * @param {number} x - The x coordinate of the point being tested.
 * @param {number} y - The y coordinate of the point being tested.
 * @returns {integer} An integer between 0 and 255.
 */
WebImage.prototype.getRed = function(x, y) {
    return this.getPixel(x, y)[RED];
};

/**
 * Get the green value at a given location in the image.
 *
 * @param {number} x - The x coordinate of the point being tested.
 * @param {number} y - The y coordinate of the point being tested.
 * @returns {integer} An integer between 0 and 255.
 */
WebImage.prototype.getGreen = function(x, y) {
    return this.getPixel(x, y)[GREEN];
};

/**
 * Get the blue value at a given location in the image.
 *
 * @param {number} x - The x coordinate of the point being tested.
 * @param {number} y - The y coordinate of the point being tested.
 * @returns {integer} An integer between 0 and 255.
 */
WebImage.prototype.getBlue = function(x, y) {
    return this.getPixel(x, y)[BLUE];
};

/**
 * Get the alpha value at a given location in the image.
 *
 * @param {number} x - The x coordinate of the point being tested.
 * @param {number} y - The y coordinate of the point being tested.
 * @returns {integer} An integer between 0 and 255.
 */
WebImage.prototype.getAlpha = function(x, y) {
    return this.getPixel(x, y)[ALPHA];
};

/**
 * Set the `component` value at a given location in the image to `val`.
 *
 * @param {number} x - The x coordinate of the point being tested.
 * @param {number} y - The y coordinate of the point being tested.
 * @param {integer} component - Integer representing the color value to
 * be set. R, G, B = 0, 1, 2, respectively.
 * @param {integer} val - The desired value of the `component` at the pixel.
 * Must be between 0 and 255.
 */
WebImage.prototype.setPixel = function(x, y, component, val) {
    if (this.data !== NOT_LOADED && !(x < 0 || y < 0 || x > this.width || y > this.height)) {
        // Update the pixel value
        var index = NUM_CHANNELS * (y * this.width + x);
        this.data.data[index + component] = val;

        // Now that we have modified the image data, we need to display
        // the image based on the underlying image data rather than the
        // image url
        this.displayFromData = true;
        this.dirtyHiddenCanvas = true;
    }
};

/**
 * Set the red value at a given location in the image to `val`.
 *
 * @param {number} x - The x coordinate of the point being tested.
 * @param {number} y - The y coordinate of the point being tested.
 * @param {integer} val - The desired value of the red component at the pixel.
 * Must be between 0 and 255.
 */
WebImage.prototype.setRed = function(x, y, val) {
    this.setPixel(x, y, RED, val);
};

/**
 * Set the green value at a given location in the image to `val`.
 *
 * @param {number} x - The x coordinate of the point being tested.
 * @param {number} y - The y coordinate of the point being tested.
 * @param {integer} val - The desired value of the green component at the pixel.
 * Must be between 0 and 255.
 */
WebImage.prototype.setGreen = function(x, y, val) {
    this.setPixel(x, y, GREEN, val);
};

/**
 * Set the blue value at a given location in the image to `val`.
 *
 * @param {number} x - The x coordinate of the point being tested.
 * @param {number} y - The y coordinate of the point being tested.
 * @param {integer} val - The desired value of the blue component at the pixel.
 * Must be between 0 and 255.
 */
WebImage.prototype.setBlue = function(x, y, val) {
    this.setPixel(x, y, BLUE, val);
};

/**
 * Set the alpha value at a given location in the image to `val`.
 *
 * @param {number} x - The x coordinate of the point being tested.
 * @param {number} y - The y coordinate of the point being tested.
 * @param {integer} val - The desired value of the alpha component at the
 * pixel.
 * Must be between 0 and 255.
 */
WebImage.prototype.setAlpha = function(x, y, val) {
    this.setPixel(x, y, ALPHA, val);
};

module.exports = WebImage;