Advertisement

Build an Image Editor With EaselJS, jQuery, and the HTML5 File API

by
Student iconAre you a student? Get a yearly Tuts+ subscription for $45 →

As HTML5 becomes more popular, more of the major browsers begin to support its APIs. Today, using the Canvas and File APIs, we can create a full-blown picture editor, with features on par with some desktop applications. For this, we will use the EaselJS library. It uses a syntax similar to AS3, so it will be easy to understand both for Flash and JavaScript programmers.


Final Result Preview

Let's take a look at the final result we will be working towards:




Click to try the demo

Play around with it to get a feel for what it's capable of. You might even want to download the full source code and take a look around before digging in to this tutorial.


Introduction

Because of the amount of code in this tutorial, I'm going to go through each files and explain every part in turn, rather than guiding you through re-building it from scratch. I will try to comment everything as much as I can, and I believe you will understand everything.


Step 1: The Style

I will start in an unusual way, from the CSS files. First create the style.css file:

 
* { font-family: Calibri, Sans-serif; outline: none; } 
body, html { margin: 0; padding: 0; overflow: hidden; background: url(background.gif); } 
canvas { clear: both; display: block; z-index: -1; } 
input { background: white; border: 1px solid black; border-radius: 4px; padding: 3px 5px; } 
input[type=text] { padding: 5px; } 
button { padding: 6px 10px; color: rgb(255, 255, 255); background: rgba(0, 0, 0, 0.3); border: 1px solid rgb(40, 40, 40); border-radius: 4px; } 
button:hover, button.hover { background: rgba(0, 0, 0, 0.2); } 
button:active, button.active { background: rgba(0, 0, 0, 0.4); } 
 
ul#mainmenu { list-style: none; padding: 2px; margin: 0; float: left; background: rgb(150, 150, 150); font-size: 1.2em; width: 778px; } 
ul#mainmenu li { float: left; margin: 0; padding: 0 2px 0 0; position: relative; } 
ul#mainmenu li button { float: left; } 
ul#mainmenu li ul.submenu { list-style: none; position: absolute; left: -2px; top: 34px; background: rgb(150, 150, 150); margin: 0; padding: 0; display: none; float: left; width: 170px; border-radius: 0 0 4px 4px; } 
ul#mainmenu li ul.submenu li { margin: 0; padding: 0; clear: both; width: 170px; } 
ul#mainmenu li ul.submenu li button { width: 162px; text-align: left; margin: 4px; padding-left: 20px; } 
ul#mainmenu li ul.submenu li button:hover { background: rgba(0, 0, 0, 0.2); } 
ul#mainmenu li ul.submenu li button:active { background: rgba(0, 0, 0, 0.4); } 
ul#mainmenu li ul.submenu li hr { margin: 4px; border-top: 1px solid rgba(0, 0, 0, 0.4); border-bottom: 1px solid rgb(205, 205, 205); } 
 
div#overlay { background: rgba(0, 0, 0, 0.6); position: fixed; width: 100%; height: 100%; top: 0; left: 0; z-index: 1; display: none; } 
 
ul#layers { width: 232px; position: fixed; right: 0; top: 37px; background: rgb(150, 150, 150); border-top: 1px dotted rgb(100, 100, 100); list-style: none; margin: 0; padding: 0 5px 5px; overflow-y: auto; overflow-x: hidden; } 
ul#layers li { margin-top: 5px; padding: 5px; background: rgb(180, 180, 180); border-radius: 4px; } 
ul#layers li.active { background: rgb(160, 160, 160); padding: 3px; border: 2px dotted black; } 
ul#layers li img { width: 42px; height: 42px; float: left; padding: 2px; color: rgb(255, 255, 255); background: rgba(0, 0, 0, 0.3); border: 1px solid rgb(40, 40, 40); border-radius: 4px; } 
ul#layers li h1 { font-size: 16px; padding: 0 5px; margin: 3px 0; width: 132px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } 
ul#layers li span { padding: 0 5px; margin: 3px 0; width: 132px; } 
ul#layers li span button { padding: 3px 5px; margin: 0 3px 0 0; } 
 
button#button-move, #button-select, #button-text { background-repeat: no-repeat; background-position: 50% 50%; padding: 6px 15px; } 
button#button-move { background-image: url(move.gif); } 
button#button-text { background-image: url(text.gif); } 
button#button-openfile input, button#button-importfile input { position: relative; left: -23px; top: -8px; width: 162px; opacity: 0; } 
 
div#dialog-tooltext select { padding: 4px; width: 173px; color: rgb(0, 0, 0); border: 1px solid rgb(40, 40, 40); border-radius: 4px; } 
 
div.dialog { border: 1px solid black; background: rgb(240, 240, 240); position: fixed; border-radius: 4px; z-index: 2; display: none; padding: 40px; } 
 
div#cropoverlay { position: fixed; left: 0; top: 0; z-index: 2; background: rgba(255, 255, 255, 0.15); width: 120px; height: 120px; border: 1px dotted black; border-radius: 0; padding: 0; display: none; } 
div#cropoverlay div { width: 20px; height: 20px; position: absolute; z-index: 1000; right: 0; bottom: 0;  border-top: 1px solid black;  border-left: 1px solid black; } 
 
.ui-resizable { position: relative;} 
.ui-resizable-handle { position: absolute;font-size: 0.1px;z-index: 99999; display: block; } 
.ui-resizable-disabled .ui-resizable-handle, .ui-resizable-autohide .ui-resizable-handle { display: none; } 
.ui-resizable-se { cursor: se-resize; width: 12px; height: 12px; right: 1px; bottom: 1px; }

On the first line we change the font and disable outlines around elements. Next there are just style definitions: ul#mainmenu is the main menu element, div#overlay is the shading under all dialogs, and ul#layers is the Layers panel that will be displayed on the right side of the canvas. Next we define the style for tool buttons, and finally we have a fragment of the jQuery-UI's style, because we will need this part for the layer cropping dialog.

Next comes the print.css file which contains only two lines to hide everything apart from the canvas when printing the image (this style is applied only when you print the page, because of its declaration in the HTML file).

 
body * { visibility: hidden; } 
canvas { visibility: visible; position: absolute; top: 0; left: 0; }

The first line hides all elements inside the body section, and the second line makes only the canvas visible (and also aligned to the top-left corner). This is because when someone wants to print the image they usually don't want to print the interface.


Step 2: The HTML Structure

You should have a basic idea of the interface from looking at the CSS files above. Now create the index.html file and enter the following lines:

 
<!DOCTYPE html> 
<html> 
<head> 
	<!-- CSS Styles --> 
	<link rel="stylesheet" type="text/css" href="style.css"/> 
	<link rel="stylesheet" type="text/css" media="print" href="print.css"/> 
	<!-- jQuery & jQuery-UI's Selectable + Draggable --> 
	<script type="text/JavaScript" src="jquery-1.7.1.min.js"></script> 
	<script type="text/JavaScript" src="jquery-ui-1.8.18.custom.min.js"></script> 
	<!-- EaselJS Canvas Interface --> 
	<script type="text/JavaScript" src="easel.js"></script> 
	<!-- EaselJS' Built-in Filters --> 
	<script type="text/JavaScript" src="ColorFilter.js"></script> 
	<script type="text/JavaScript" src="ColorMatrixFilter.js"></script> 
	 
	<!-- Main Application Structure --> 
	<script type="text/JavaScript" src="main.js"></script> 
	<!-- User Interface --> 
	<script type="text/JavaScript" src="ui.js"></script> 
	<!-- File Menu --> 
	<script type="text/JavaScript" src="file.js"></script> 
	<!-- Tools --> 
	<script type="text/JavaScript" src="tools.js"></script> 
	<!-- Layer Transformations --> 
	<script type="text/JavaScript" src="layer.js"></script> 
	<!-- Image Transformations --> 
	<script type="text/JavaScript" src="image.js"></script> 
	<!-- Custom Filters --> 
	<script type="text/JavaScript" src="ConvolutionFilter.js"></script> 
	<script type="text/JavaScript" src="filters.js"></script> 
	<!-- Simple Scripting System --> 
	<script type="text/JavaScript" src="scripts.js"></script> 
</head> 
<body> 
	<!-- Shade for all Dialogs --> 
	<div id="overlay"></div> 
	<!-- Crop Layer Element --> 
	<div id="cropoverlay" class="dialog"> 
		<div></div> 
		<button style="position: absolute; top: -33px;" class="button-ok">Crop</button><button style="position: absolute; top: -33px; left: 50px;"  class="button-cancel">Cancel</button> 
	</div> 
	<!-- Various Dialogs --> 
	<div id="dialog-openurl" class="dialog"> 
		Please enter url to open:<br> 
		<input type="text" style="width: 350px;"/> 
		<button class="button-ok">Ok</button><button class="button-cancel">Cancel</button> 
	</div> 
	<div id="dialog-scale" class="dialog"> 
		Set scale:<br> 
		X: <input class="input-scaleX" type="text" style="width: 50px; text-align: right;" value="100"/>%  
		Y: <input class="input-scaleY" type="text" style="width: 50px; text-align: right;" value="100"/>%  
		<button class="button-ok">Ok</button><button class="button-cancel">Cancel</button> 
	</div> 
	<div id="dialog-rotate" class="dialog"> 
		Rotate:<br> 
		<input type="text" style="width: 50px; text-align: right;" value="0"/>°  
		<button class="button-ok">Ok</button><button class="button-cancel">Cancel</button> 
	</div> 
	<div id="dialog-skew" class="dialog"> 
		Skew:<br> 
		X: <input class="input-skewX" type="text" style="width: 50px; text-align: right;" value="100"/>°  
		Y: <input class="input-skewY" type="text" style="width: 50px; text-align: right;" value="100"/>°  
		<button class="button-ok">Ok</button><button class="button-cancel">Cancel</button> 
	</div> 
	<div id="dialog-layerrename" class="dialog"> 
		Rename layer:<br> 
		<input type="text" style="width: 350px;"/> 
		<button class="button-ok">Ok</button><button class="button-cancel">Cancel</button> 
	</div> 
	<div id="dialog-tooltext" class="dialog"> 
		Add text layer:<br> 
		Font:  
		<select> 
			<option value="Calibri">Calibri</option> 
			<option value="Times New Roman">Times New Roman</option> 
			<option value="Courier New">Courier New</option> 
		</select>  
		Size: <input type="text" class="input-size" style="width: 50px" value="12px"/> 
		Color: <input type="text" class="input-color" style="width: 70px; background: black; color: silver;" value="black"/><br> 
		<input type="text" class="input-text" style="width: 318px"/> 
		<button class="button-ok">Ok</button><button class="button-cancel">Cancel</button> 
	</div> 
	<div id="dialog-filterbrightness" class="dialog"> 
		Set brightness:<br> 
		<input type="text" style="width: 50px;"/>%  
		<button class="button-ok">Ok</button><button class="button-cancel">Cancel</button> 
	</div> 
	<div id="dialog-filterblur" class="dialog"> 
		Blur radius:<br> 
		<input type="text" style="width: 50px;"/>px  
		<button class="button-ok">Ok</button><button class="button-cancel">Cancel</button> 
	</div> 
	<div id="dialog-filtercolorify" class="dialog"> 
		Colorify:<br> 
		R: <input class="r" type="text" style="width: 30px;"/>  
		G: <input class="g" type="text" style="width: 30px;"/>  
		B: <input class="b" type="text" style="width: 30px;"/>  
		A: <input class="a" type="text" style="width: 30px;"/>  
		<button class="button-ok">Ok</button><button class="button-cancel">Cancel</button> 
	</div> 
	<div id="dialog-filtergaussianblur" class="dialog"> 
		Blur radius:<br> 
		<input type="radio" class="7" name="radius"/> 3px  
		<input type="radio" class="5" name="radius"/> 2px  
		<input type="radio" class="3" name="radius"/> 1px &nbsp; 
		<button class="button-ok">Ok</button><button class="button-cancel">Cancel</button> 
	</div> 
	<div id="dialog-executescript" class="dialog"> 
		Execute script:<br> 
		<textarea style="width: 350px; height: 200px;"></textarea><br> 
		<button class="button-ok">Execute</button><button class="button-cancel">Cancel</button> 
	</div> 
	<!-- Main Menu --> 
	<ul id="mainmenu"> 
		<li> 
			<button>File</button> 
			<ul class="submenu"> 
				<li><button id="button-openfile"><input type="file"/><span style="margin-top: -32px; display: block;">Open File</span></button></li> 
				<li><button id="button-openurl">Open URL</button></li> 
				<li><hr/></li> 
				<li><button id="button-importfile"><input type="file" multiple="true"/><span style="margin-top: -32px; display: block;">Import File</span></button></li> 
				<li><button id="button-importurl">Import URL</button></li> 
				<li><hr/></li> 
				<li><button id="button-save">Save</button></li> 
				<li><button id="button-print">Print</button></li> 
			</ul> 
		</li> 
		<li> 
			<button>Edit</button> 
			<ul class="submenu"> 
				<li><button id="button-undo">Undo</button></li> 
				<li><button id="button-redo">Redo</button></li> 
			</ul> 
		</li> 
		<li> 
			<button>Layer</button> 
			<ul class="submenu"> 
				<li><button id="button-layercrop">Crop</button></li> 
				<li><button id="button-layerscale">Scale</button></li> 
				<li><hr/></li> 
				<li><button id="button-layerrotate">Rotate</button></li> 
				<li><button id="button-layerskew">Skew</button></li> 
				<li><button id="button-layerflipv">Flip Vertically</button></li> 
				<li><button id="button-layerfliph">Flip Horizontally</button></li> 
			</ul> 
		</li> 
		<li> 
			<button>Image</button> 
			<ul class="submenu"> 
				<li><button id="button-imagescale">Scale</button></li> 
				<li><hr/></li> 
				<li><button id="button-imagerotate">Rotate</button></li> 
				<li><button id="button-imageskew">Skew</button></li> 
				<li><button id="button-imageflipv">Flip Vertically</button></li> 
				<li><button id="button-imagefliph">Flip Horizontally</button></li> 
			</ul> 
		</li> 
		<li> 
			<button>Filters</button> 
			<ul class="submenu"> 
				<li><button id="button-filterbrightness">Brigtness</button></li> 
				<li><button id="button-filtercolorify">Colorify</button></li> 
				<li><button id="button-filterdesaturation">Desaturation</button></li> 
				<li><hr/></li> 
				<li><button id="button-filterblur">Blur</button></li> 
				<li><button id="button-filtergaussianblur">Gaussian Blur</button></li> 
				<li><button id="button-filteredgedetection">Edge Detection</button></li> 
				<li><button id="button-filteredgeenhance">Edge Enhance</button></li> 
				<li><button id="button-filteremboss">Emboss</button></li> 
				<li><button id="button-filtersharpen">Sharpen</button></li> 
			</ul> 
		</li> 
		<li> 
			<button id="button-executescript">Execute Script</button> 
		</li> 
		<li style="float: right;"><button id="button-text"/></li> 
		<li style="float: right;"><button id="button-move"/></li> 
		<li style="float: right;"><button id="button-select" class="active"/></li> 
	</ul> 
	<!-- Right Layer Panel --> 
	<ul id="layers"></ul> 
	<!-- Canvas for Drawing --> 
	<canvas/> 
</body> 
</html>

Notice the HTML5's doctype specification. No useless, long DTD specifications; just the word html.

At the top, we link the necessary libraries and JS files. These are all included in the source download; the specific libraries are EaselJS, jQuery, and jQuery UI.

(For an introduction to EaselJS, check out this tutorial.)

Next we construct the whole UI structure. Just remember: every div with a class of dialog is just a dialog for the user to input data needed to perform some operation on image. If you run this code in your browser now you will notice a few 404 errors in the console and the that the menu will not work, but we will fix that when we create the ui.js file.


Step 3: Main Application Object

It's a good practice to wrap all your application related functions and variables inside a single object, to prevent them from being overriden by external libraries or even your own scripts. Our object will look like this:

 
app = { 
	stage: null, 
	canvas: null, 
	layers: [], 
	tool: TOOL_SELECT, 
	callbacks: {}, 
	selection: { 
		x: -1, y: -1 
	}, 
	renameLayer: 0, 
	undoBuffer: [], 
	redoBuffer: [] 
}

(I start all variable and function names with a small letter based on Douglas Crockford's Code Conventions for the JavaScript Programming Language.)

In app.stage we will hold a reference to the Stage object for our application. If you've coded anything in ActionScript, think of this Stage as being like AS3's. It has a display list which is drawn to the canvas element on each update. The app.canvas is variable with reference to canvas element inside our html document. We will use it to create the Stage and to resize it along with the window.

The app.layers array will hold all image layers, and app.tool contains value of actually selected tool. The app.callbacks will hold every event callback we will need to specify (e.g. clicking menu button), app.renameLayer holds the number of the actually renamed layer, and app.undoBuffer and app.redoBuffer are arrays to hold backed-up app.layers state to make undo and redo functions work.

You will also need to add these four lines before the app definition (they are just tool ID constants):

 
const  
	TOOL_MOVE = 0, 
	TOOL_SELECT = 1, 
	TOOL_TEXT = 2;

Step 4: Useful Methods

Now, we will define the methods of this object. First add the following refreshLayers() and sortLayers() methods:

 
refreshLayers: function () { 
	if ((this.getActiveLayer() == undefined) && (this.layers.length > 0)) this.layers[0].active = true; 
	this.stage = new Stage(this.canvas); 
	this.stage.regX = -this.canvas.width / 2; 
	this.stage.regY = -this.canvas.height / 2; 
	 
 
	app.layers.toString = function () { 
		var ret = []; 
		for (var i = 0, layer; layer = this[i]; i++) { 
			ret.push('{"x":' + layer.x + ',"y":' + layer.y + ',"scaleX":' + layer.scaleX + ',"scaleY":' + layer.scaleY + ',"skewX":' + layer.skewX + ',"skewY":' + layer.skewY + ',"active":' + layer.active + ',"visible":' + layer.visible + ',"filters":{"names":[' + (layer.filters != null ? layer.filters.toString().replace(/(\[|\])/g, '"'): 'null') + '],"values":[' + JSON.stringify(layer.filters) + ']}}'); 
		} 
		return '[' + ret.join(',') + ']'; 
	} 
	 
	$('ul#layers').html(''); 
	for (var i = 0, layer; layer = this.layers[i]; i++) { 
		var self = this; 
		self.stage.addChild(layer); 
		(function(t, n) { 
			layer.onClick = function (e) { 
				if ((self.tool != TOOL_TEXT) || (!t.text)) return true; 
				self.activateLayer(t); 
				editText = true; 
			} 
			 
			layer.onPress = function (e1) { 
				if (self.tool == TOOL_SELECT) { 
					self.activateLayer(t); 
				} 
				 
				var	offset = { 
					x: t.x - e1.stageX, 
					y: t.y - e1.stageY 
				} 
				 
				if (self.tool == TOOL_MOVE) self.addUndo(); 
				 
				e1.onMouseMove = function (e2) { 
					if (self.tool == TOOL_MOVE) { 
						t.x = offset.x + e2.stageX; 
						t.y = offset.y + e2.stageY; 
					} 
				} 
			}; 
		})(layer, i); 
		layer.width = (layer.text != null ? layer.getMeasuredWidth() * layer.scaleX: layer.image.width * layer.scaleX); 
		layer.height = (layer.text != null ? layer.getMeasuredLineHeight() * layer.scaleY: layer.image.height * layer.scaleY); 
		layer.regX = layer.width / 2; 
		layer.regY = layer.height / 2; 
		$('ul#layers').prepend('<li id="layer-' + i + '" class="' + (layer.active ? 'active': '') + '"><img src="' + (layer.text != undefined ? '': layer.image.src) + '"/><h1>' + ((layer.name != null) && (layer.name != '') ? layer.name: 'Unnamed layer') + '</h1><span><button class="button-delete">Delete</button><button class="button-hide">' + (layer.visible ? 'Hide': 'Show') + '</button><button class="button-rename">Rename</button></span></li>'); 
	} 
	this.stage.update(); 
	$('ul#layers').sortable({ 
		stop: function () { 
			app.sortLayers(); 
		} 
	}); 
	 
	if (this.layers.length > 0) { 
		$('#button-layercrop').attr('disabled', false); 
		$('#button-layerscale').attr('disabled', false); 
		$('#button-layerrotate').attr('disabled', false); 
		$('#button-layerskew').attr('disabled', false); 
		$('#button-layerflipv').attr('disabled', false); 
		$('#button-layerfliph').attr('disabled', false); 
		$('#button-imagescale').attr('disabled', false); 
		$('#button-imagerotate').attr('disabled', false); 
		$('#button-imageskew').attr('disabled', false); 
		$('#button-imageflipv').attr('disabled', false); 
		$('#button-imagefliph').attr('disabled', false); 
		$('#button-filterbrightness').attr('disabled', false); 
		$('#button-filtercolorify').attr('disabled', false); 
		$('#button-filterdesaturation').attr('disabled', false); 
		$('#button-filterblur').attr('disabled', false); 
		$('#button-filtergaussianblur').attr('disabled', false); 
		$('#button-filteredgedetection').attr('disabled', false); 
		$('#button-filteredgeenhance').attr('disabled', false); 
		$('#button-filteremboss').attr('disabled', false); 
		$('#button-filtersharpen').attr('disabled', false); 
	} else { 
		$('#button-layercrop').attr('disabled', true); 
		$('#button-layerscale').attr('disabled', true); 
		$('#button-layerrotate').attr('disabled', true); 
		$('#button-layerskew').attr('disabled', true); 
		$('#button-layerflipv').attr('disabled', true); 
		$('#button-layerfliph').attr('disabled', true); 
		$('#button-imagescale').attr('disabled', true); 
		$('#button-imagerotate').attr('disabled', true); 
		$('#button-imageskew').attr('disabled', true); 
		$('#button-imageflipv').attr('disabled', true); 
		$('#button-imagefliph').attr('disabled', true); 
		$('#button-filterbrightness').attr('disabled', true); 
		$('#button-filtercolorify').attr('disabled', true); 
		$('#button-filterdesaturation').attr('disabled', true); 
		$('#button-filterblur').attr('disabled', true); 
		$('#button-filtergaussianblur').attr('disabled', true); 
		$('#button-filteredgedetection').attr('disabled', true); 
		$('#button-filteredgeenhance').attr('disabled', true); 
		$('#button-filteremboss').attr('disabled', true); 
		$('#button-filtersharpen').attr('disabled', true); 
	} 
}, 
 
sortLayers: function () { 
	var tempLayers = [], 
		layersList = $('ul#layers li'); 
		 
	for (var i = 0, layer; layer = $(layersList[i]); i++) { 
		if (layer.attr('id') == undefined) break; 
		tempLayers[i] = this.layers[layer.attr('id').replace('layer-', '') * 1]; 
	} 
	 
	tempLayers.reverse(); 
	this.layers = tempLayers; 
	this.refreshLayers(); 
}

Please note that inside an object you need to declare variables and functions with ":" instead of "=".

The sortLayers() method is called when the user drags layers within the Layers panel;refreshLayers() is called very often, because it recreates the app.stage and adds all layers to the stage once again, setting their properties and applying event callbacks. These callbacks enables you to move layers and edit text on text layers. This is a very important function as it also adds all layers to the Layers panel in the UI and disables tool buttons in the menu (if there are no layers) and enables them as well (when there is at least one layer).

Before the refreshLayers(), insert another set of helper functions (remember to add a comma after the last one!):

 
getActiveLayer: function () { 
	var ret; 
	this.layers.forEach(function(v) { 
		if (v.active) ret = v; 
	}); 
	if ((ret == undefined) && (this.layers.length > 0)) return this.layers[0]; 
	return ret; 
}, 
 
getActiveLayerN: function () { 
	for (var i = 0, layer; layer = this.layers[i]; i++) { 
		if (layer.active) return i; 
	} 
}, 
 
activateLayer: function (layer) { 
	this.layers.forEach(function (v) { 
		v.active = false; 
	}); 
	if (layer instanceof Bitmap) { 
		layer.active = true; 
	} else  { 
		if (this.layers[layer] == undefined) return; 
		this.layers[layer].active = true; 
	} 
	this.refreshLayers(); 
},

An active layer is the one to which you are applying all operations (tranformations, adding filters, etc.). You can activate a layer by clicking on it on the Layers panel or using the Select tool on the canvas.

As you can see the activateLayer() method parameter can be either a Bitmap or a number. If it is a Bitmap - the EaselJS object for images - then its active property is set to true, and if it is a number then the layer on this position in the app.layers array is activated. getActiveLayer() just returns the layer which is active and getActiveLayerN() returns the position of the active layer within the app.layers array.

The last bunch of methods in this object should be insterted directly after app.redoBuffer declaration and before the ones you put there earlier:

 
addUndo: function () { 
	this.undoBuffer.push(this.layers.toString()); 
	this.redoBuffer = []; 
}, 
 
loadLayers: function (from, to) { 
	var json, jsonString = from.pop(); 
	if (jsonString == undefined) return false; 
	to.push(this.layers.toString()); 
	json = JSON.parse(jsonString); 
	for (var i = 0, layer, jsonLayer; ((layer = this.layers[i]) && (jsonLayer = json[i])); i++) { 
		for (value in jsonLayer) { 
			if (value != 'filters')	{ 
				layer[value] = jsonLayer[value]; 
			} else { 
				var hadFilters = (layer.filters != null && layer.filters.length > 0); 
				layer.filters = []; 
				for (var j = 0; j < jsonLayer.filters.names.length; j++) { 
					if (jsonLayer.filters.names[j] == null) break; 
					layer.filters[j] = new window[jsonLayer.filters.names[j]]; 
					for (value2 in jsonLayer.filters.values[0][j]) { 
						layer.filters[j][value2] = jsonLayer.filters.values[0][j][value2]; 
					} 
					hadFilters = true; 
				} 
				if (hadFilters) { 
					if (layer.cacheCanvas) { 
						layer.updateCache(); 
					} else { 
						layer.cache(0, 0, layer.width, layer.height); 
					} 
				} 
			} 
		} 
	} 
	this.refreshLayers(); 
}, 
 
undo: function () { 
	this.loadLayers(this.undoBuffer, this.redoBuffer); 
}, 
 
redo: function () { 
	this.loadLayers(this.redoBuffer, this.undoBuffer); 
},

As you shoud notice when reading app.refreshLayers(), the toString() method of app.layers is overridden by the code which prepares stringified version of all layers inside. Of course it would be a waste of memory to keep all the layer information there, so only the values that can be changed by application are backed up.

The addUndo() method pushes actual state of the layers to app.undoBuffer array and clears the app.redoBuffer - because when you make an action that can be undone then you cannot redo anything that was undone before that action. loadLayers() takes two arguments (the array from which we should pop the state of app.layers and the array to which we should push the actual state of this variable), and performs parsing of the backed-up app.layers.

As EaselJS's filter example says:

"... filters are only displayed when the display object is cached ..."

(From the EaselJS Examples: Filters.)

This means that you need to call cache() method of the Bitmap to apply the filter. Caching is performed to improve performance - the filter is applied only once and only the filtered bitmap is drawn. EaselJS is caching content in a very clever way - it just copies it to another canvas element which is not added to the document (it's hidden). I mention this because at the end of the loadLayers() method there is an if block which checks whether there are any filters which should be updated on this layer - and if there are, it updates cache or caches element.


Step 5: Initialization

Initialization of the whole application is simple; just insert this after the app declaration:

 
tick = function () { 
	app.stage.update(); 
} 
 
$(document).ready(function () { 
	app.canvas = $('canvas')[0]; 
	 
	document.onselectstart = function () { return false; }; 
	 
	Ticker.setFPS(30); 
	Ticker.addListener(window); 
});

Ticker is a EaselJS-implemented timer which calls the listener's tick() function to maintain the stable FPS set earlier. This way we can automatically call app.stage.update() to redraw the Stage.

At the beginning (just after the document has loaded) we assign the first canvas element on the page that the $ (jQuery) function finds to app.canvas, then we disable selecting of anything on document (because otherwise when you drag mouse across the canvas there is a effect as if you are selecting text).

We set Ticker's FPS to 30 (you need only 24 frames per second to trick the human eye into thinking it's seeing movement) and set the window as the Ticker's listener.


Step 6: UI Helper Functions

Now it is time to bring our menu and the whole user interface to life. The ui.js file will be composed almost entirely of jQuery functions, so it is really easy to understand. Let's begin with helper functions:

 
importFile = false; 
 
hideDialog = function (dialog) { 
	$(dialog).hide(); 
	if ($('.dialog:visible').length == 0) $('#overlay').hide(); 
	editText = false; 
} 
 
showDialog = function (dialog) { 
	$('#overlay').show(); 
	$(dialog).show(); 
}

The importFile variable will inform us of whether we are opening a file or importing it.

The names of the showDialog() and hideDialog() functions speak for themselves - though one interesing thing in the hideDialog() function is how it checks whether all dialogs are hidden with the jQuery ':visible' pseudo-class, only to then hide the overlay. In the end it proved to be useless because there is no situation that more than one dialog is on screen, but I left it for your future use; maybe it will come in handy.


Step 7: Resize The Stage

Now we should do something when user resizes the browser window. This is when the window's resize event comes into play. It is fired every time the user resizes the browser window:

 
$(window).resize(function () { 
	$('.dialog').each(function () { 
		$(this).css({ left: window.innerWidth / 2 - $(this).outerWidth() / 2 + 'px', top: window.innerHeight / 2 - $(this).outerHeight() / 2 + 'px' }); 
	}); 
	 
	$('canvas').attr('height', $(window).height() - 37).attr('width', $(window).width() - 232); 
	$('ul#mainmenu').width($(window).width() - 4); 
	$('ul#layers').css({ height: $(window).height() - 37 }); 
	 
	app.refreshLayers(); 
	 
	if ($('#cropoverlay').css('display') == 'block') { 
		$('#cropoverlay').css({  
			left: Math.ceil(app.canvas.width / 2 - app.getActiveLayer().x - app.getActiveLayer().regX - 1) + 'px',  
			top: Math.ceil(app.canvas.height / 2 + app.getActiveLayer().y - app.getActiveLayer().regY + 38) + 'px' 
		}); 
	} 
});

First we center all dialogs using jQuery's each() function. This calls the callback for every item which is matched by the selector in the $ function.

Then, we've got to set the canvas's width and height - but not in CSS because this would strech the images inside of the canvas, and we do not want that. The menu height is 37px so we set the canvas' height to the window's height minus 37px. Same for the width, but this time we've got to subtract the width of the Layers panel which is 232px. We're also resizing the menu and Layers panel to fit the window (here we can use CSS).

After that we need to refresh the layers to make sure they are always up-to-date when window is resized. The last thing is to move the crop dialog in case the user resized the window when cropping the layer.


Step 8: Binding All Together

The menu's buttons must be bound to the callbacks specified in app.callbacks, and also we need to bind the keydown event for inputs and click for dialog buttons. The last sentence may sound complicated, but when you see the code it will become clear:

 
$(document).ready(function () { 
	$("ul#mainmenu li button").click(function () { 
		$(this).focus(); 
		$(this).parent().find("ul.submenu:visible").slideUp('fast').show(); 
		$(this).parent().find("ul.submenu:hidden").slideDown('fast').show(); 
	}); 
	 
	$("ul#mainmenu li button").blur(function () { 
		$(this).parent().find("ul.submenu:visible").delay(100).slideUp('fast').show(); 
	}); 
 
	$('#button-openfile').hover( 
		function () { $(this).addClass('hover'); }, 
		function () { $(this).removeClass('hover'); } 
	); 
 
	$('#button-importfile').hover( 
		function () { $(this).addClass('hover'); }, 
		function () { $(this).removeClass('hover'); } 
	); 
 
	$('#button-openurl').click(function () { 
		importFile = false; 
		showDialog('#dialog-openurl'); 
		$('#dialog-openurl input').val('').attr('disabled', false).focus(); 
	}); 
 
	$('#button-importurl').click(function () { 
		importFile = true; 
		showDialog('#dialog-openurl'); 
		$('#dialog-openurl input').val('').attr('disabled', false).focus(); 
	}); 
	 
	$('#button-undo').click(function () { app.undo(); }); 
	$('#button-redo').click(function () { app.redo(); }); 
	 
	$('#button-layerscale').click(function () { 
		affectImage = false; 
		showDialog('#dialog-scale'); 
		$('#dialog-scale input.input-scaleX').val('100'); 
		$('#dialog-scale input.input-scaleY').val('100'); 
	}); 
	 
	$('#button-layerskew').click(function () { 
		affectImage = false; 
		showDialog('#dialog-skew'); 
		$('#dialog-skew input.input-scaleX').val('100'); 
		$('#dialog-skew input.input-scaleY').val('100'); 
	}); 
	 
	$('#button-layerrotate').click(function () { 
		affectImage = false; 
		showDialog('#dialog-rotate'); 
		$('#dialog-rotate input').val('0'); 
	}); 
	 
	$('#button-layercrop').click(function () { 
		affectImage = false; 
		app.sortLayers(); 
		app.refreshLayers(); 
		var layer = app.getActiveLayer(); 
		$('#overlay').show(); 
		$('#cropoverlay').css({ 
			left: Math.ceil(app.canvas.width / 2 + layer.x - layer.regX - 1) + 'px', 
			top: Math.ceil(app.canvas.height / 2 + layer.y - layer.regY + 38) + 'px',  
			width: (layer.text != null ? layer.getMeasuredWidth() * layer.scaleX: layer.image.width * layer.scaleX) + 2 + 'px',  
			height: (layer.text != null ? layer.getMeasuredLineHeight() * layer.scaleY: layer.image.height * layer.scaleY) + 2 + 'px' 
		}).show(); 
	}); 
	 
	$('#button-layerflipv').click(app.callbacks.layerFlipV); 
	$('#button-layerfliph').click(app.callbacks.layerFlipH); 
	 
	$('#button-imagescale').click(function () { 
		affectImage = true; 
		showDialog('#dialog-scale'); 
		$('#dialog-scale input.input-scaleX').val('100'); 
		$('#dialog-scale input.input-scaleY').val('100'); 
	}); 
	 
	$('#button-imageskew').click(function () { 
		affectImage = true; 
		showDialog('#dialog-skew'); 
		$('#dialog-skew input.input-scaleX').val('100'); 
		$('#dialog-skew input.input-scaleY').val('100'); 
	}); 
	 
	$('#button-imagerotate').click(function () { 
		affectImage = true; 
		showDialog('#dialog-rotate'); 
		$('#dialog-rotate input').val('0'); 
	}); 
	 
	$('#button-imageskew').click(function () { 
		affectImage = true; 
		showDialog('#dialog-skew'); 
		$('#dialog-skew input.input-skewX').val('0'); 
		$('#dialog-skew input.input-skewY').val('0'); 
	}); 
	 
	$('#button-filterbrightness').click(function () { 
		showDialog('#dialog-filterbrightness'); 
		$('#dialog-filterbrightness input').val('100'); 
	}); 
	 
	$('#button-filtercolorify').click(function () { 
		showDialog('#dialog-filtercolorify'); 
		$('#dialog-filtercolorify input').val('0'); 
	}); 
	 
	$('#button-filterblur').click(function () { 
		showDialog('#dialog-filterblur'); 
		$('#dialog-filterblur input').val('1'); 
	}); 
	 
	$('#button-filtergaussianblur').click(function () { 
		showDialog('#dialog-filtergaussianblur'); 
		$('#dialog-filtergaussianblur input.7').attr('checked', true); 
	}); 
	 
	$('#button-executescript').click(function () { 
		showDialog('#dialog-executescript'); 
		$('#dialog-executescript textarea').val(''); 
	}); 
	 
	$('#button-select').click(function () { 
		app.tool = TOOL_SELECT; 
		$('#mainmenu button').removeClass('active'); 
		$(this).addClass('active'); 
	}); 
	 
	$('#button-move').click(function () { 
		app.tool = TOOL_MOVE; 
		$('#mainmenu button').removeClass('active'); 
		$(this).addClass('active'); 
	}); 
	 
	$('#button-text').click(function () { 
		app.tool = TOOL_TEXT; 
		$('#mainmenu button').removeClass('active'); 
		$(this).addClass('active'); 
	}); 
	 
	$('#button-imageflipv').click(app.callbacks.imageFlipV); 
	$('#button-imagefliph').click(app.callbacks.imageFlipH); 
	 
	$('#dialog-openurl input').keydown(app.callbacks.openURL); 
	$('#dialog-openurl button.button-ok').click(app.callbacks.openURL); 
	$('#dialog-scale input').keydown(app.callbacks.numberOnly).keydown(app.callbacks.layerScale); 
	$('#dialog-scale button.button-ok').click(app.callbacks.layerScale); 
	$('#button-openfile input').change(app.callbacks.openFile); 
	$('#button-importfile input').change(app.callbacks.importFile); 
	$('#dialog-tooltext button.button-ok').click(app.callbacks.toolText); 
	$('#dialog-tooltext input').keydown(app.callbacks.toolText); 
	$('#dialog-layerrename button.button-ok').click(app.callbacks.layerRename); 
	$('#dialog-layerrename input').keydown(app.callbacks.layerRename); 
	$('#dialog-rotate button.button-ok').click(app.callbacks.layerRotate); 
	$('#dialog-rotate input').keydown(app.callbacks.numberOnly).keydown(app.callbacks.layerRotate); 
	$('#dialog-skew button.button-ok').click(app.callbacks.layerSkew); 
	$('#dialog-skew input').keydown(app.callbacks.numberOnly).keydown(app.callbacks.layerSkew); 
	$('#cropoverlay button.button-ok').click(app.callbacks.layerCrop); 
	$('#button-filterdesaturation').click(app.callbacks.filterDesaturation); 
	$('#button-filteredgedetection').click(app.callbacks.filterEdgeDetection); 
	$('#button-filteredgeenhance').click(app.callbacks.filterEdgeEnhance); 
	$('#button-filteremboss').click(app.callbacks.filterEmboss); 
	$('#button-filtersharpen').click(app.callbacks.filterSharpen); 
	$('#dialog-filterbrightness button.button-ok').click(app.callbacks.filterBrightness); 
	$('#dialog-filterbrightness input').keydown(app.callbacks.numberOnly).keydown(app.callbacks.filterBrightness); 
	$('#dialog-filtergaussianblur button.button-ok').click(app.callbacks.filterGaussianBlur); 
	$('#dialog-filtergaussianblur input').keydown(app.callbacks.numberOnly).keydown(app.callbacks.filterGaussianBlur); 
	$('#dialog-filterblur button.button-ok').click(app.callbacks.filterBlur); 
	$('#dialog-filterblur input').keydown(app.callbacks.numberOnly).keydown(app.callbacks.filterBlur); 
	$('#dialog-filtercolorify button.button-ok').click(app.callbacks.filterColorify); 
	$('#dialog-filtercolorify input').keydown(app.callbacks.numberOnly).keydown(app.callbacks.filterColorify); 
	$('#dialog-executescript button.button-ok').click(app.callbacks.scriptExecute); 
	$('#button-save').click(app.callbacks.saveFile); 
	$('#button-print').click(app.callbacks.printFile); 
	 
	$('#dialog-tooltext input.input-color').keyup(function (e) { 
		$(this).css({ backgroundColor: $(this).val() }); 
	}); 
	 
	$('ul#layers li').live('click', function () { 
		app.activateLayer($(this).attr('id').replace('layer-', '') * 1); 
	}); 
	 
	$('ul#layers li button.button-delete').live('click', function () { 
		app.layers.splice($(this).parent().parent().attr('id').replace('layer-', '') * 1, 1); 
		this.undoBuffer = []; 
		this.redoBuffer = []; 
		app.refreshLayers(); 
	}); 
	 
	$('ul#layers li button.button-hide').live('click', function () { 
		if ($(this).text() == 'Hide') { 
			app.layers[$(this).parent().parent().attr('id').replace('layer-', '') * 1].visible = false; 
		} else { 
			app.layers[$(this).parent().parent().attr('id').replace('layer-', '') * 1].visible = true; 
		} 
		app.refreshLayers(); 
	}); 
	 
	$('ul#layers li button.button-rename').live('click', function () { 
		$('#dialog-layerrename').show(); 
		$('#overlay').show(); 
		$('#dialog-layerrename input').val(''); 
		app.renameLayer = $(this).parent().parent().attr('id').replace('layer-', '') * 1; 
	}); 
	 
	$(document).keydown(function (e) { 
		if (e.keyCode == 27) { 
			hideDialog('.dialog'); 
		} 
	}); 
	 
	$('.dialog button.button-cancel').each(function () { 
		$(this).click(function () { 
			hideDialog($(this).parent()); 
		}); 
	}); 
	 
	$('canvas').click(app.callbacks.toolText); 
	 
	$('#cropoverlay').draggable().resizable({ 
		handles: 'se', 
		resize: function (e, ui) { 
			$('#cropoverlay').css({ left: ui.position.left + 'px', top: ui.position.top + 'px' }); 
		}, 
		stop: function (e, ui) { 
			$('#cropoverlay').css({ left: ui.position.left + 'px', top: ui.position.top + 'px' }); 
		} 
	}); 
	 
	$(window).resize(); 
});

Another thing to remember: when you do anything with jQuery and any HTML elements, do it inside the document.ready callback, because only then can you be sure that all elements you are using are already rendered.

The above code is long, but that's because there are many parts which are similar but differ in parts that do not allow us to wrap it in any helper function. The highlighted part is where we set all callbacks for dialog buttons and inputs.

Next you should look at the live() function we use to bind click events to layer buttons (on the Layer panel) - the live() function adds callback for every element that will match this selector in the future, making it very useful since we generate a new layer list on every app.refreshLayers() call.

The last function here is $(window).resize() which manually fires the resize event of a window. This is why it is important to link scripts in order, because if ui.js were added to the HTML before main.js, the layers would be refreshed before the function definition, which can sometimes lead to unexpected results, making finding the bug even harder.

If you run the application now you will see a nicely working menu and proper resizing of the UI, but still no button will do anything other than throwing errors to the console when you click it.


Step 9: Opening The Files

Now we will use another API from HTML5 specification: the File API. It enables us to open files from the user's computer, but only when he chooses them in the OS's file input field (to prevent web apps from stealing your private data).

Please note that if you will be running this application on local computer you need to setup a local server or add the --allow-file-access-from-files parameter when running Chrome, because opening files from within local web pages is disabled by default.

In the file.js file we will also put functions to save and print the image, so let's start with these four helpers:

 
openFile = function (url, first) { 
	var img = new Image(); 
		 
	img.onload = function () { 
		var n = (first ? 0: app.layers.length); 
		if (first) app.layers = []; 
		app.layers[n] = new Bitmap(img); 
		app.layers[n].x = 0; 
		app.layers[n].y = 0; 
		app.activateLayer(n); 
	} 
	img.src = url; 
	 
	this.undoBuffer = []; 
	this.redoBuffer = []; 
} 
 
openURL = function (self, url) { 
	$(self).attr('disabled', true); 
	openFile(url, !importFile); 
	hideDialog('#dialog-openurl'); 
} 
 
saveFile = function () { 
	window.open(app.stage.toDataURL()); 
} 
 
printFile = function () { 
	window.print() 
}

The openFile function will be used to open the image and add it to the layers. If we selected 'Open File' from the menu then the old content would be erased, whereas 'Import File' would add new layers to the image. (In the function, if the first parameter is true then we are opening the file, otherwise we are importing it).

openURL opens the file using the same function, but from an external source (and as far as I know Chrome disables access to cross-domain origin pixel data which makes those images pretty useless). Because we cannot save the file to the user's disk we just open another window containing just an image representing the actual Stage; the user can then right-click to save the image.

Printing is achieved by calling window.print(). We can't, of course, print anything without letting the user know, so this function will open the default printing dialog where the user can choose printing preferences.

Now we will define some callbacks for buttons inside the menu:

 
app.callbacks.openFile = function (e) { 
	var file = e.target.files[0], 
		self = this; 
	 
	if (!file.type.match('image.*')) return false; 
 
	var reader = new FileReader(); 
	reader.onload = function(e) { 
		openFile(e.target.result, true); 
	}; 
 
	reader.readAsDataURL(file); 
}; 
 
app.callbacks.openURL = function (e) { 
	switch (e.type) { 
		case "click": 
			openURL($('#dialog-openurl input'), $('#dialog-openurl input').val()); 
			break; 
		case "keydown": 
			if (e.keyCode == 13) openURL(this, $(this).val()); 
			break; 
	} 
} 
 
app.callbacks.importFile = function (e) { 
	for (var i = 0, file; file = e.target.files[i]; i++) { 
		if (!file.type.match('image.*')) continue; 
 
		var reader = new FileReader(); 
		reader.onload = function(e) { 
			openFile(e.target.result, false); 
		}; 
 
		reader.readAsDataURL(file); 
	} 
}; 
 
app.callbacks.saveFile = function () { 
	saveFile(); 
} 
 
app.callbacks.printFile = function () { 
	printFile(); 
}

As you can see the code is very short yet very powerful. In the first (app.callbacks.openFile) callback we check whether the opened file is an image, and stop if it is not. Then we create a new FileReader, set its onload callback to open the file, and invoke the readAsDataURL(file) method which loads the file and outputs the result as a data URL for us to read.

(Also please note that we are clearing undo and redo arrays; we must do this because we can't restore the image if we delete it - the user must manually reselect the file from input.)

Save the file, open the app in your browser, and you can finally do something! Not much, but if you are doing this for the first time it is probably exciting to be able to load some images into the browser, even if you can only move them.


Step 10: Text Layers

Now that you can open and import images you could add some text. There is one really useful thing with the canvas - you define text just like in a CSS font attribute. And EaselJS fully uses that feature.

We will define a Text tool in the tools.js file. Add the following lines:

 
editText = false; 
 
toolText = function (text, font, color, size, x, y) { 
	var n = (editText ? app.getActiveLayerN(): app.layers.length); 
	app.layers[n] = new Text(text, size + ' ' + font, color); 
	app.layers[n].x = x - app.canvas.width / 2; 
	app.layers[n].y = y - app.canvas.height / 2; 
	app.layers[n].name = text; 
	app.activateLayer(n); 
	hideDialog('#dialog-tooltext'); 
	 
	this.undoBuffer = []; 
	this.redoBuffer = []; 
} 
 
app.callbacks.toolText = function (e) { 
	switch (e.type) { 
		case "click": 
			if (e.target instanceof HTMLButtonElement) { 
				toolText($('#dialog-tooltext input.input-text').val(), $('#dialog-tooltext select').val(), $('#dialog-tooltext input.input-color').val(), $('#dialog-tooltext input.input-size').val(), (editText ? app.getActiveLayer().x: app.selection.x), (editText ? app.getActiveLayer().y: app.selection.y)); 
			} else { 
				if (app.tool != TOOL_TEXT) return true; 
				$('#dialog-tooltext').show(); 
				$('#overlay').show(); 
				app.selection.x = e.offsetX; 
				app.selection.y = e.offsetY; 
				$('#dialog-tooltext input.input-text').val((editText ? app.getActiveLayer().text: '')); 
				$('#dialog-tooltext input.input-size').val((editText ? app.getActiveLayer().font.split(' ')[0]: '12px')); 
				$('#dialog-tooltext select').val((editText ? app.getActiveLayer().font.split(' ')[1]: 'Calibri')); 
				$('#dialog-tooltext input.input-color').val((editText ? app.getActiveLayer().color: 'black')); 
				$('#dialog-tooltext input.input-color').css({ backgroundColor: $('#dialog-tooltext input.input-color').val() }); 
			} 
			break; 
		case "keydown": 
			if (e.keyCode == 13) toolText($('#dialog-tooltext input.input-text').val(), $('#dialog-tooltext select').val(), $('#dialog-tooltext input.input-color').val(), $('#dialog-tooltext input.input-size').val(), (editText ? app.getActiveLayer().x: app.selection.x), (editText ? app.getActiveLayer().y: app.selection.y)); 
			break; 
	} 
}

The editText variable is true when we are changing properties of the existing text layer instead of creating a new one.

The first function is, as usual, a helper function. It checks whether we are editing an existing text layer or adding a new one, then creates a new Text object and adds it to application layers. Because all objects in EaselJS extend the base DisplayObject, we can use both Text and Bitmap in the same way; only their properties differ.

The second function is a callback. First thing we need to do is to check what type of event we are receiving (because we use only one callback both for handling button click and pressing enter inside the input). Then we just call the previous helper.


Step 11: Layer Transformations

Simple layer transformations are indeed really simple with EaselJS. Everything that I will show here can be done just by changing layer (Bitmap or Text) properties.

I'll start by explaining the registration point. The regX and regY properties specify the point from which rotation and position are calculated - it's like a handle. In the main.js file we set this point to the center of the image, to make transforming layers easier. All layer transformation functions will go in the layer.js file.

 
affectImage = false; 
 
layerScale = function (x, y) { 
	app.addUndo(); 
	if (affectImage) return imageScale(x, y); 
	app.getActiveLayer().scaleX *= x / 100; 
	app.getActiveLayer().scaleY *= y / 100; 
	hideDialog('#dialog-scale'); 
} 
 
layerRotate = function (deg) { 
	app.addUndo(); 
	if (affectImage) return imageRotate(deg); 
	app.getActiveLayer().rotation += deg; 
	hideDialog('#dialog-rotate'); 
} 
 
layerSkew = function (degx, degy) { 
	app.addUndo(); 
	if (affectImage) return imageSkew(degx, degy); 
	app.getActiveLayer().skewX += degx; 
	app.getActiveLayer().skewY += degy; 
	hideDialog('#dialog-skew'); 
} 
 
layerFlipH = function () { 
	app.addUndo(); 
	app.getActiveLayer().scaleX = -app.getActiveLayer().scaleX; 
} 
 
layerFlipV = function () { 
	app.addUndo(); 
	app.getActiveLayer().scaleY = -app.getActiveLayer().scaleY; 
}

In this part there are - as usual - helper functions. There are few things I want to say about this piece of code.

First: we need to call the addUndo() function before we start changing anything. Why? Because we want to go back to the state before some operation was performed when we click the undo button.

Also notice the affectImage variable. We will set it to true when we want to affect the whole image; in almost every function there is an if statement checking if we are affecting the whole image, and (if so) returning the result of the appropriate image*() function.

Now put the callbacks code inside of the file:

 
app.callbacks.numberOnly = function (e) { 
	if ((e.shiftKey) || ([8, 13, 37, 38, 39, 40, 46, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 190, 189].indexOf(e.keyCode) < 0)) return false; 
} 
 
app.callbacks.layerRename = function (e) { 
	switch (e.type) { 
		case "click": 
			app.layers[app.renameLayer].name = $('#dialog-layerrename input').val(); 
			app.refreshLayers(); 
			hideDialog('#dialog-layerrename'); 
			break; 
		case "keydown": 
			if (e.keyCode == 13) { 
				app.layers[app.renameLayer].name = $('#dialog-layerrename input').val(); 
				app.refreshLayers(); 
				hideDialog('#dialog-layerrename'); 
			} 
			break; 
	} 
} 
 
app.callbacks.layerScale = function (e) { 
	switch (e.type) { 
		case "click": 
			layerScale($('#dialog-scale input.input-scaleX').val() * 1, $('#dialog-scale input.input-scaleY').val() * 1); 
			break; 
		case "keydown": 
			if (e.keyCode == 13) layerScale($('#dialog-scale input.input-scaleX').val() * 1, $('#dialog-scale input.input-scaleY').val() * 1); 
			break; 
	} 
} 
 
app.callbacks.layerRotate = function (e) { 
	switch (e.type) { 
		case "click": 
			layerRotate($('#dialog-rotate input').val() * 1); 
			break; 
		case "keydown": 
			if (e.keyCode == 13) layerRotate($(this).val() * 1); 
			break; 
	} 
	 
} 
 
app.callbacks.layerSkew = function (e) { 
	switch (e.type) { 
		case "click": 
			layerSkew($('#dialog-skew input.input-skewX').val() / 100, $('#dialog-skew input.input-skewY').val() / 100); 
			break; 
		case "keydown": 
			if (e.keyCode == 13) layerSkew($('#dialog-skew input.input-skewX').val() / 100, $('#dialog-skew input.input-skewY').val() / 100); 
			break; 
	} 
} 
 
app.callbacks.layerFlipV = function () { layerFlipV(); } 
app.callbacks.layerFlipH = function () { layerFlipH(); } 
 
app.callbacks.layerCrop = function () { 
	var layer = app.getActiveLayer(); 
	layer.cache( 
		Math.floor(app.canvas.width / 2 - $('#cropoverlay').position().left - layer.regX + layer.x - 1), 
		Math.floor(app.canvas.height / 2 - $('#cropoverlay').position().top - layer.regY + layer.y + 38), 
		$('#cropoverlay').width(), 
		$('#cropoverlay').height() 
	); 
	$(this).parent().find('.button-cancel').click(); 
}

Another bunch of jQuery calls. We also check the event type because we can get those functions called by button or input field. Every function in the above code is much the same: check event type, get input value from dialog and call function.

The callback on the top of this part of the code is bound to inputs that should only get numbers inside them.


Step 12: Transformations: Scale

Let's start by adding this source code; I will explain it later. Put the code below into the image.js file:

 
imageScale = function (x, y) { 
	for (var i = 0, layer; layer = app.layers[i]; i++) { 
		layer.scaleX *= x / 100; 
		layer.scaleY *= y / 100; 
		layer.x *= x / 100; 
		layer.y *= y / 100; 
	} 
	hideDialog('#dialog-scale'); 
	affectImage = false; 
}

We are just looping through all layers setting their scaleX and scaleY properties. But the image would look weird if we only scale layers. We also need to move every layer to make this function work fine.


Step 13: Image Transformations: Rotation

Rotation will be a little harder than scaling. But first is the code; put this also into the image.js file:

 
imageRotate = function (deg) { 
	for (var i = 0, layer; layer = app.layers[i]; i++) { 
		layer.rotation += deg; 
		var rad = deg * Math.PI / 180, 
			x = (layer.x * Math.cos(rad)) - (layer.y * Math.sin(rad)), 
			y = (layer.x * Math.sin(rad)) + (layer.y * Math.cos(rad)); 
		layer.x = x; 
		layer.y = y; 
	} 
	hideDialog('#dialog-rotate'); 
	affectImage = false; 
}

We are of course adding to the layer rotation, but the code I highlighted is new. It is based on the equations of rotating the point in the Cartesian coordinate system:

Where Φ is the angle. So the highlighted code is just the translation of the above equations into JavaScript code (plus the conversion from degrees to radians, because trigonometry functions in Math library take radians as parameters, and one radian is exactly pi/180 degrees).


Step 14: Transformations: Skew

Skewing is very similar to rotation, because basically it is rotation but with two different angles for two directions. Take a look at the code:

 
imageSkew = function (degx, degy) { 
	for (var i = 0, layer; layer = app.layers[i]; i++) { 
		layer.skewX += degx; 
		layer.skewY += degy; 
		var radx = degx * Math.PI / 180, 
			rady = degy * Math.PI / 180, 
			x = (layer.x * Math.cos(radx)) - (layer.y * Math.sin(radx)), 
			y = (layer.x * Math.sin(rady)) + (layer.y * Math.cos(rady)); 
		layer.x = x; 
		layer.y = y; 
	} 
	hideDialog('#dialog-skew'); 
	affectImage = false; 
}

You see the difference? We are just using radx for the x position and rady for the y position. This makes the image skew properly (which gives a pretty nice effect, I must say).


Step 15: Transformations: Flip

This is a modification of the imageScale() function. It is not last because it is the hardest of the image transformation functions, just because it is on the last position in the menu. The code:

 
imageFlipH = function () { 
	app.addUndo(); 
	for (var i = 0, layer; layer = app.layers[i]; i++) { 
		layer.scaleX = -layer.scaleX; 
		layer.x = -layer.x; 
	} 
	affectImage = false; 
} 
 
imageFlipV = function () { 
	app.addUndo(); 
	for (var i = 0, layer; layer = app.layers[i]; i++) { 
		layer.scaleY = -layer.scaleY; 
		layer.y = -layer.y; 
	} 
	affectImage = false; 
} 
 
app.callbacks.imageFlipV = function () { 
	imageFlipV(); 
} 
 
app.callbacks.imageFlipH = function () { 
	imageFlipH(); 
}

Of course we can ignore the callbacks - we already know what do they do. We should focus on the first two functions. They are just looping through the layers setting their scaleX or scaleY to the opposite value - this is what we know as flipping: just a negative scale. Also the x or y must be reversed to make the image really look flipped.

This was the last of the image transformations. Now we are going to make something more advanced - filters!


Step 16: Simple Filters: Introduction

I call them simple because we are using filters which are built in to EaselJS: ColorFilter and ColorMatrixFilter. These modify the image pixel by pixel, so with big images and complicated filters you can make the browser lag for a while or even stop completely.

Before we apply them I will explain what each filter does.

ColorFilter takes eight parameters on creation:

 
new ColorFilter(redMultiplier, greenMultiplier, blueMultiplier, alphaMultiplier, redOffset, greenOffset, blueOffset, alphaOffset);

When the filter is applied it splits the image into four channels (red, green, blue and alpha) and for every channel it multiplies each value by the corresponding multiplier and adds the corresponding offset. (Actually, the image is not really split; this is a handy metaphor.)

ColorMatrixFilter takes only one parameter on creation:

 
new ColorMatrixFilter(matrix);

The matrix has the following format:

 
[ 
	rr, rg, rb, ra, ro, 
	gr, gg, gb, ga, go, 
	br, bg, bb, ba, bo, 
	ar, ag, ab, aa, ao 
]

When this filter is applied, it also (metaphorically) splits image into channels, and then it multiplies each value by each other. For example, the equation for the value of a pixel in the red channel after passing through the filter is:

 
	newRed = (red * rr) + (green * rg) + (blue * rb) + (alpha * ra) + ro;

This is the same for green, blue and alpha also, only with different variables from matrix (gr,gg,gb,ga for green, and so on). This filter is a little more advanced than the ColorFilter, because each color depends on other colors of the pixel.

For more details, see this tutorial.


Step 17: Simple Filters: Helper Function

This is one of the two helper functions that will be used here, but we will use it for every filter, also for the advanced ones.

Put this code in the beginning of the filters.js file:

 
applyFilter = function (filter) { 
	app.addUndo(); 
	var layer = app.getActiveLayer(); 
	 
	layer.filters = (layer.filters ? layer.filters: []); 
	layer.filters.push(filter); 
	if (layer.cacheCanvas) { 
		layer.updateCache(); 
	} else { 
		layer.cache(0, 0, layer.width, layer.height); 
	} 
}

It does all the work for us: grabs the active layer; if there are no filters then creates a filters array; and adds the filter.

After that we've got to cache the layer for the filter effects to be visible, so we check whether we've already cached this layer (for example when cropping it) and call updateCache() or cache() as appropriate.

Here is the image I'll use to show the effects of the filters:



Step 18: Simple Filters: Brightness

For this effect we will use the ColorFilter, because changing the brightness is just changing all channel values (red, green, blue) in the layer by the same value.

Here is the code (put it into the filters.js file):

 
filterBrightness = function (value) { 
	applyFilter(new ColorFilter(value, value, value, 1)); 
	hideDialog('#dialog-filterbrightness'); 
}

As I mentioned earlier, our helper function is doing everything for us, we only need to create new filter. Here we are creating ColorFilter with the red, green, blue multipliers set to value and alpha set to 1.0 (we do not want the alpha to be touched by this filter).

Below is an example result of this filter:



Step 19: Simple Filters: Colorify

Colorify will make some channels value bigger or smaller to change the overall color of the image, so we will again use the ColorFilter.

Take a look at the code:

 
filterColorify = function (r, g, b, a) { 
	applyFilter(new ColorFilter(1.0, 1.0, 1.0, 1.0, r, g, b, a)); 
	hideDialog('#dialog-filtercolorify'); 
}

Again, the dirty work is handled by applyFilter, and we only focus on creating the filter object. Here we will be using the last four parameters of the ColorFilter constructor. They are added to the channels, so they perfectly fit our needs.

Below is an example result of this filter:



Step 20: Simple Filters: Desaturation

Desaturation is the process of removing the saturation - in simple words, making the image black and white. To do that we need to calculate the luminosity of each pixel and set all colors to this value. The simpliest luminosity equation involves only adding the same amount of all colors, and for that we can use the ColorMatrixFilter:

 
filterDesaturation = function () { 
	applyFilter(new ColorMatrixFilter( 
		[ 
			0.33, 0.33, 0.33, 0.00, 0.00, 
			0.33, 0.33, 0.33, 0.00, 0.00, 
			0.33, 0.33, 0.33, 0.00, 0.00, 
			0.00, 0.00, 0.00, 1.00, 0.00 
		] 
	));	 
	hideDialog('#dialog-filterbrightness'); 
}

As I said earlier - take the equal amount of three colors and add them. We again do not touch alpha as it do not contain any color values.

There is no example result image because it is already black and white; the filter has no effect.


Step 21: Convolution Filters

The Convolution Filter is a little more advanced than ColorMatrixFilter. It also uses a matrix, but the convolution matrix represents the multipliers of pixels surrounding actual pixel.

Let's say that we have this example 3x3 convolution matrix (already represented as a JavaScript array):

 
[ 
	[ 0, 0, 0], 
	[-1, 1, 0], 
	[ 0, 0, 0] 
]

And (for example) we are looking at a part of the image where the pixels look like this (each number represents the strength of the red color channel; we ignore the rest for simplicity):

 
	[ 00 ] [ 12 ] [ 43 ] 
	[ 12 ] [ 56 ] [ 62 ] 
	[ 63 ] [ 67 ] [ 92 ]

With the convolution filter, we are modifying the pixel in the middle (current value: 56). So we start by multiplying every color value around that pixel by its multiplier from the convolution array, and then we add them together. We get the following equation:

 
newPixelValue = (00 * 0) + (12 * 0) + (43 * 0) 
              + (12 * -1) + (56 * 1) + (62 * 0)  
              + (63 * 0) + (67 * 0) + (92 * 0)  
              = (-12) + 56  
              = 44

So now we set the pixel's new red channel value to 44 - but in a new array of data, because we still need to hold on to the old value of 56 for modifying the other pixels in the image. This means that, when applying the filter, we actually create a copy of the image rather than modifying the existing one in-place.

With a larger matrix, you can see how the formula would get more complex, as each pixel depends on data from more surrounding pixels; for this reason, a bigger matrix requires a longer running time. When running a convolution filter, the browser will usually freeze for a while.

All advanced filters that we will create will depend on this filter, so it will be better if you understand how it works. Again, more information is available here.

Also you have to remember that the sum of all values inside the convolution matrix must be equal to either zero or one - otherwise you will get weird results. To make this simpler we can use the factor and the offset variables. After calculating the pixel value (with the above equation) we multiply the whole value by the factor and add the offset. This simplifies creating, for example, the blur filter (which we'll get to in a minute), where all convolution matrix values are the same.

Sadly there is no ConvolutionFilter implementation in EaselJS, so we have to write one. Following the example of ColorFilter I created this code:

 
(function (window) { 
	var ConvolutionFilter = function (matrix, factor, offset) { 
		this.initialize(matrix, factor, offset); 
	} 
	 
	var p = ConvolutionFilter.prototype = new Filter(); 
	 
	p.matrix = null; 
	p.factor = 0.0; 
	p.offset = 0.0; 
		 
	p.initialize = function (matrix, factor, offset) { 
		this.matrix = matrix; 
		this.factor = factor; 
		this.offset = offset; 
	} 
	 
	p.applyFilter = function (ctx, x, y, width, height, targetCtx, targetX, targetY) { 
		targetCtx = targetCtx || ctx; 
		targetX = (targetX == null ? x: targetX); 
		targetY = (targetY == null ? y: targetY); 
		 
		try { 
			var imageData = ctx.getImageData(x, y, width, height); 
		} catch (e) { 
			return false; 
		} 
		 
		var data = JSON.parse(JSON.stringify(imageData.data)); 
		 
		var matrixhalf = Math.floor(this.matrix.length / 2); 
		var r = 0, g = 1, b = 2, a = 3; 
		 
		for (var y = 0; y < height; y++) { 
			for (var x = 0; x < width; x++) { 
				var pixel = (y * width + x) * 4, 
					sumr = 0, sumg = 0, sumb = 0; 
				for (var matrixy in this.matrix) { 
					for (var matrixx in this.matrix[matrixy]) { 
						var convpixel = ((y + (matrixy - matrixhalf)) * width + (x + (matrixx - matrixhalf))) * 4; 
						sumr += data[convpixel + r] * this.matrix[matrixy][matrixx]; 
						sumg += data[convpixel + g] * this.matrix[matrixy][matrixx]; 
						sumb += data[convpixel + b] * this.matrix[matrixy][matrixx]; 
					} 
				} 
				imageData.data[pixel + r] = this.factor * sumr + this.offset; 
				imageData.data[pixel + g] = this.factor * sumg + this.offset; 
				imageData.data[pixel + b] = this.factor * sumb + this.offset; 
				imageData.data[pixel + a] = data[pixel + a]; 
			} 
		} 
		 
		targetCtx.putImageData(imageData, targetX, targetY); 
		return true; 
	} 
 
	p.toString = function() { 
		return "[ConvolutionFilter]"; 
	} 
	 
	p.clone = function() { 
		return new ConvolutionFilter(this.matrix, this.factor, this.offset); 
	} 
	 
	window.ConvolutionFilter = ConvolutionFilter; 
}(window));

You can skip all the methods except for applyFilter, as they are used by EaselJS to initialise the filter.

applyFilter() is invoked when we apply a filter to the image. First we have to get the image data from canvas, then I use a trick with JSON.parse(JSON.stringify(imageData.data)) - because we want a copy of the image data, and imageData.data object dont have a clone() or slice() method to achieve this, so we use this tricky to completely copy the object and all of its properties.

Color information is stored in this data like this:

 
[ ... ][ red ][ green ][ blue ][ alpha ][ red ][ green ][ blue ][ alpha ][ ... ]

So every pixel takes four array items - one for each channel. Finally, after iterating through all pixel data, we call putImageData() on the target to save the result.


Step 22: Convolution Filters: Blur

This is the simplest convolution effect, so I decided to let the user set the radius of the filter (which will result in setting the size of the array) to make it more complex. Here is the function we will use:

 
filterBlur = function (radius) { 
	var matrix = []; 
	 
	for (var y = 0; y < radius * 2; y++) { 
		matrix[y] = []; 
		for (var x = 0; x < radius * 2; x++) { 
			matrix[y][x] = 1; 
		} 
	} 
	 
	applyFilter(new ConvolutionFilter(matrix, 1.0 / Math.pow(radius * 2, 2), 0.0)); 
	hideDialog('#dialog-filterblur'); 
}

It is generating the blur matrix, then applies the filter.

Why do we set factor to Math.pow(radius * 2, 2)? Because as I said earlier: the sum of all array fields must be equal to zero or one; if we divide them all by their sum we will always get 1.

Below is the result of this filter:



Step 23: Gaussian Blur


This convolution filter is so-called because it uses values from the Gaussian standard distribution (pictured above) inserted into the convolution matrix. To simplify the task we let the user choose only three radius values, because applying the filter for a bigger radius will take too much time (a 3px radius is already a 7 by 7 matrix).

Here is the function:

 
var gaussMatrix = [ 
	[ 
		[ 0.05472157, 0.11098164, 0.05472157 ], 
		[ 0.11098164, 0.22508352, 0.11098164 ], 
		[ 0.05472157, 0.11098164, 0.05472157 ] 
	], 
	[ 
		[ 0.00078633, 0.00655965, 0.01330373, 0.00655965, 0.00078633 ], 
		[ 0.00655965, 0.05472157, 0.11098164, 0.05472157, 0.00655965 ], 
		[ 0.01330373, 0.11098164, 0.22508352, 0.11098164, 0.01330373 ], 
		[ 0.00655965, 0.05472157, 0.11098164, 0.05472157, 0.00655965 ], 
		[ 0.00078633, 0.00655965, 0.01330373, 0.00655965, 0.00078633 ] 
	], 
	[ 
		[ 0.00000067, 0.00002292, 0.00019117, 0.00038771, 0.00019117, 0.00002292, 0.00000067 ], 
		[ 0.00002292, 0.00078633, 0.00655965, 0.01330373, 0.00655965, 0.00078633, 0.00002292 ], 
		[ 0.00019117, 0.00655965, 0.05472157, 0.11098164, 0.05472157, 0.00655965, 0.00019117 ], 
		[ 0.00038771, 0.01330373, 0.11098164, 0.22508352, 0.11098164, 0.01330373, 0.00038771 ], 
		[ 0.00019117, 0.00655965, 0.05472157, 0.11098164, 0.05472157, 0.00655965, 0.00019117 ], 
		[ 0.00002292, 0.00078633, 0.00655965, 0.01330373, 0.00655965, 0.00078633, 0.00002292 ], 
		[ 0.00000067, 0.00002292, 0.00019117, 0.00038771, 0.00019117, 0.00002292, 0.00000067 ] 
	] 
]; 
 
filterGaussianBlur = function (radius) { 
	applyFilter(new ConvolutionFilter(gaussMatrix[radius], 1.0, 0.0)); 
	hideDialog('#dialog-filtergaussianblur'); 
}

We specify the matrices outside of the function so as not to waste time assigning it every time the user selects it from the menu. In this function we simply choose the specified matrix and apply the filter. You can of course add more radius values if you want to.

Below is the result of this filter:



Step 24: Edge Detection


Edge detection is a technique often used in robots' AI to help them move around, because edge detection leaves only the edges of the image. It is also a really nice effect to use in art.

To achieve this we use approximation of the first values from the Laplace distribution (image above) with b = 1/4. All functions from this point will only have different matrices:

 
filterEdgeDetection = function () { 
	applyFilter(new ConvolutionFilter( 
		[ 
			[  0, -1,  0 ], 
			[ -1,  4, -1 ], 
			[  0, -1,  0 ] 
		], 
		1.0, 
		0.0 
	)); 
	hideDialog('#dialog-filteredgedetection'); 
}

Below is the result of this filter:



Step 25: Edge Enhance

This filter is similar in effect to the previous, but it enhances the edges without blacking out the rest of the image - making it perfect for artistic use. This is actually the matrix I used to explain to you how convolution filters work:

 
filterEdgeEnhance = function () { 
	applyFilter(new ConvolutionFilter( 
		[ 
			[  0, 0, 0 ], 
			[ -1, 1, 0 ], 
			[  0, 0, 0 ] 
		], 
		1.0, 
		0.0 
	)); 
	hideDialog('#dialog-filteredgeenhance'); 
}

Below is the result of this filter:



Step 26: Emboss

Emboss filter adds a slight 3D effect to the image by highlighting the left-bottom corners of the edges (so it is also an edge detection filter).

The function:

 
filterEmboss = function () { 
	applyFilter(new ConvolutionFilter( 
		[ 
			[ -1, -1, 0 ], 
			[ -1,  1, 1 ], 
			[  0,  1, 1 ] 
		], 
		1.0, 
		0.0 
	)); 
	hideDialog('#dialog-filteremboss'); 
}

Below is the result of this filter:



Step 27: Sharpen

We all know what sharpening is. It is achieved by slight modification of the edge detection:

 
filterSharpen = function () { 
	applyFilter(new ConvolutionFilter( 
		[ 
			[  0, -1,  0 ], 
			[ -1,  5, -1 ], 
			[  0, -1,  0 ] 
		], 
		1.0, 
		0.0 
	)); 
	hideDialog('#dialog-filtersharpen'); 
}

Below is the result of this filter:


You see the difference? It is really small in the convolution matrix, but the resulting image is more sharp.

That was the last of the filters, but you can add more if you want. Just search for some on the Internet or experiment to create your own unique ones.


Step 28: Filters Callbacks

This will be the next bunch of jQuery calls, but before that we need our second helper function in this file:

 
filterSwitch = function (e, val, func) { 
	switch (e.type) { 
		case "click": 
			func(val); 
			break; 
		case "keydown": 
			if (e.keyCode == 13) func(val); 
			break; 
	} 
}

It takes three parameters:

  • e - the event object,
  • val - the value to pass to the function, and
  • func - the function to call with the previous value.
  • It creates a shorthand we can use in the following callback code; just paste it under the helper:

     
    app.callbacks.filterBrightness = function (e) { 
    	var val = $('#dialog-filterbrightness input').val() / 100; 
    	filterSwitch(e, val, filterBrightness); 
    } 
     
    app.callbacks.filterDesaturation = function () { 
    	filterDesaturation(); 
    } 
     
    app.callbacks.filterColorify = function (e) { 
    	var r = $('#dialog-filtercolorify input.r').val() * 1, 
    		g = $('#dialog-filtercolorify input.g').val() * 1, 
    		b = $('#dialog-filtercolorify input.b').val() * 1, 
    		a = $('#dialog-filtercolorify input.a').val() * 1; 
    	switch (e.type) { 
    		case "click": 
    			filterColorify(r, g, b, a); 
    			break; 
    		case "keydown": 
    			if (e.keyCode == 13) filterColorify(r, g, b, a); 
    			break; 
    	} 
    } 
     
    app.callbacks.filterBlur = function (e) { 
    	var val = $('#dialog-filterblur input').val() * 1; 
    	filterSwitch(e, val, filterBlur); 
    } 
     
    app.callbacks.filterGaussianBlur = function (e) { 
    	var val = ($('#dialog-filtergaussianblur input.3').attr('checked') ? 2: $('#dialog-filtergaussianblur input.2').attr('checked') ? 1: 0); 
    	filterSwitch(e, val, filterGaussianBlur); 
    } 
     
    app.callbacks.filterEdgeDetection = function (e) { 
    	filterEdgeDetection(); 
    } 
     
    app.callbacks.filterEdgeEnhance = function (e) { 
    	filterEdgeEnhance(); 
    } 
     
    app.callbacks.filterEmboss = function (e) { 
    	filterEmboss(); 
    } 
     
    app.callbacks.filterSharpen = function (e) { 
    	filterSharpen(); 
    }

    Step 29: Scripting: Introduction

    Allowing user to use some scripting language in your application is very useful feature. It allows user to automate his work, or when he achieves some nice effect he can share it with someone else, and this person will get the same effect too. And because we are writing the whole application in JavaScript - which is a scripting language itself - it is really easy to create such a feature.

    Using the eval() function we can run some JavaScript from a string, and this string will be the user's script.

    You have probably read that using the eval() function is very bad practice. Of course if you have to use it inside of your code, then this sentence is true, because this disables any cacheing or compiling that modern JavaScript engines use to speed the code up. It also creates another instance of the JavaScript parser, which wastes the memory until it finishes with the code. So avoid using the eval() like this:

     
    var myEvilCondition = "someObjectPropertyButIDontKnowWhichOne"; 
    function evilEval () { 
    	return eval('myObject.' + myEvilCondition); 
    }

    The code above is very bad. You should never use eval() like this. The above example can be fixed using square brackets:

     
    var myNotEvilCondition = "someObjectPropertyButIDontKnowWhichOne"; 
    function goodNoEval () { 
    	return myObject[myNotEvilCondition]; 
    }

    In our case everything is ok, because writing your own interpreter would be a massive waste of time and resources (meaning more or bigger files for user to download).


    Step 30: Scripting: Secure Eval

    Because we are executing user scripts we have to be sure that he does not accidentally destroy the result he worked on because he mistyped function names or layer numbers. That is why he have to make sure that user cannot access any window or app methods directly.

    For this reason, we make our function look like this:

     
    scriptExecute = function (code) { 
    	hideDialog('#dialog-executescript'); 
    	if ((code.match(/eval\(/g) != null) && (!confirm('You used the eval function inside of your code. This may lead to unexpected effects, do you want to continue?'))) return; 
    	eval(code.replace(/(window\.|app\.)(.*?);/g, '')); 
    }

    (Put this code into the scripts.js file.)

    We hide the dialog first because it would otherwise just hang there until the script finishes, and maybe the user wants to see his script at work. Then we call the provided code, but we replace all window and app related calls, so the user can't delete all layers or close the window by mistake.

    A little warning here: the user can still do something with these variables if he uses the eval() function in his code - but then we ask him if he really wants to do this.

    Now we have to add this little callback function:

     
    app.callbacks.scriptExecute = function (e) { 
    	scriptExecute($('#dialog-executescript textarea').val()); 
    }

    That is the complete scripting system. Go ahead and check it out by passing some nice code to the 'Execute Script' dialog. Try to use the eval() there to see that it is asking you whether you really want to do this.


    Conclusion

    As you can see the HTML5 Canvas is a powerful thing. But we only scratched the surface of what can be done with it. We have created a really advanced application - which allows users to load their photos and make some modifications to them, then save and print the edited photo - using pure JavaScript. A few years ago that would have been a joke.

    Also you are welcome to expand the application you just created! Add more filters, change the interface, add more useful functions (for example you could add more properties to the layer, maybe a list of all filters with possibility of removing and editing them, or a button to convert the undo buffer into ready script for user to share). Just be creative and maybe you will create some really useful stuff!

    Thanks for reading this tutorial, I hope I really taught you something that you will use in some big project. If you need any help in creation of something advanced with HTML5 feel free to ask me on my contact email or by adding a comment to this tutorial. I will answer you as soon as I get your message.

Advertisement