Learn Adobe AIR, Part I: Build A Note Storage App

The Adobe Integrated Runtime (AIR) platform changes the game for web developers, taking standard web technologies such as HTML, CSS, and JavaScript, and bringing them into a desktop application environment. In this tutorial, I’ll show you how to build a personal notes database with Adobe AIR.

Make sure you pay close attention though – there will be a quiz at the end! The first 100 people to complete the quiz will win a copy of the pocket guide Adobe AIR For JavaScript Developers, delivered to their front door for FREE, thanks to Adobe. You can also download the book in PDF format for free for a limited time, so get cracking!

In this article, we’re going to explore the client-side capabilities of Adobe AIR by building a simple, local notes database – think of it as your own personal paste bin. We’ll walk through the process of setting up an AIR development workflow, putting together a basic interface, and then enabling it with many of AIR’s front-end and back-end features.

If you’re new to Adobe AIR, have a read through my previous article, Walk on AIR: Create a To-do List in Five Minutes – it will give you a good head start. For the application we’re about to build, I’ll assume you’ve at least dabbled with the Adobe AIR platform, and you’re comfortable with HTML and object oriented programming in JavaScript too.

Also, if you’re planning on playing along at home, you’ll want to grab the code archive for this article. I’ve made available the skeleton files from which to start, the completed application, and the final packaged AIR app.

A Powerful Tool for Building AIR Apps

We’ll use Aptana Studio to build our application – if you haven’t installed this tool yet, download and install Aptana Studio before reading any further.

We’ll also install the AIR plugin for Aptana. When you first launch Aptana, you’ll be asked to choose a workspace. Create a new folder to store your AIR projects (it can always be changed later, should you need to). You’ll see the welcome screen when Aptana loads, as displayed below.

Figure 1. The download page for the Aptana Studio AIR plugin

Select Download and install to install the AIR plugin, and select the Adobe AIR Support check box, as I’ve done below.

Figure 2. Installing the AIR plugin for Aptana Studio

You’ll need to restart Aptana for the plugin to take effect.

Additional Tools

In my previous article, I introduced the command line tools that Adobe make available as part of the AIR Software Development Kit (SDK). To refresh your memory, there are two main tools:

  1. The AIR Debug Launcher (ADL), which allows us to run AIR applications on-the-fly, and monitor debug output.
  2. The AIR Developer Tool (ADT), which provides a set of tools to package applications for distribution.

If you’re not sold on using Aptana, feel free to explore these alternatives. The official Adobe AIR documentation contains more details.

Aptana conveniently abstracts the ADL and ADT tools for us. There are some additional utilities in the SDK that we’ll also take advantage of – the AIR Introspector and the AIR Source Viewer. When we start a new AIR project, Aptana includes the JavaScript references for these tools in our <head> section of the HTML:

<!-- Uncomment the following line to add introspection.  When running the application hit F12 to bring up the introspector --> 
<script type="text/javascript" src="AIRIntrospector.js"></script>

<!-- Uncomment the following line to use the AIR source viewer -->
<script type="text/javascript" src="AIRSourceViewer.js"></script>

With the AIRIntrospector.js reference in place, you can display the Adobe AIR HTML/JS Application Introspector by pressing F12 while running an AIR application, shown below:

Figure 3. The HTML view of the Adobe AIR HTML/JavaScript Introspector

Look familiar? Think of it as Firebug for Adobe AIR – it has a JavaScript console, a page inspector with point-and-click element selection, a DOM Inspector, and all the usual tools you’d expect. If you’ve used Firebug, you’ll pick up this tool up in no time; if not, you might want to check out the Introspector documentation.

Here’s a demonstration of the tool in action – check out the source viewer by using the JS Console in the Introspector – just call air.SourceViewer.getDefault().viewSource():

Figure 4. The Adobe AIR Introspector in action

This tool is highly configurable using JavaScript – check out the official documentation page for more details.

Working with a Database

We’ll use an SQLite database in our sample application, and fortunately AIR comes with a built-in SQLite driver. While we can construct our database on-the-fly, it’s more practical (and more efficient) to distribute a pre-populated database. I recommend using the open source SQLite Database Browser to get up and running quickly (also available as a Firefox extension). It runs on Windows, OS X, and GNU/Linux, and the binary packages work straight out of the box. Grab a copy from the SourceForge download page.

If you haven’t used SQLite before, it functions similarly to most relational databases – with a few exceptions; in particular, it applies data types to values (cells), rather than containers (columns) – read more about SQLite and data types on the SQLite web site.

Building a Personal Notes Database Application

OK, let’s begin! For a personal notes database, we need to be able to view our notes, create new ones, and delete existing ones. While we build this application, we’ll make use of the many client-side features for powerful UI functionality in AIR. We’ll use the jQuery library for some basic interface work, but we could just as well have used Prototype, MooTools, or even Adobe Spry. Most JavaScript frameworks can be used reliably inside AIR.

In terms of our app’s functionality, we’ll be touching on each of the following areas:

  • native menus
  • file system management
  • local SQLite databases
  • clipboard operations, native copy-and-paste
  • user interface niceties

We’ll start by creating a template for our user interface, and then we’ll add each of the above features progressively.

Defining the User Interface

AIR’s built-in browser uses the WebKit rendering engine, which does a fantastic job of adhering to web standards. I find it practical to build the prototype of my interface in HTML, then test it in Firefox. Doing so means I can rely on the eternally useful Firebug tool to iron out any kinks. I’ve mocked up a basic interface for our Notes application in HTML, which is shown below. It includes a New note form, which we’ll hide when it’s not needed.

Figure 5. A prototype of our Notes application, built in HTML

As you can see, it’s a fairly simple layout; each note contains:

  • a title
  • a listing in a box
  • a timestamp of when it was created
  • a red Delete button showing a minus sign (courtesy of the Silk icon set)

For us developers who write lots of code, the ability to store monospaced text notes would be really handy, so we’ll surround our actual note contents with <pre> tags.

I’ve prototyped this in Firefox, but I’ve taken the actual screenshot in an AIR window itself. Notice how AIR adds some default chrome to some of the elements (that grey area around the edge) This is optional (see the AIR documentation for more details). We’re also displaying containers with rounded corners in our app; since we know that our app will always run within an AIR window, we don’t have the same cross-browser compatibility woes that we experience on the Web. We can therefore make use of WebKit’s many special CSS properties – in this case, the –webkit-border-radius property.

Creating the Database

We’ll use a simple, single-table SQLite database to store our notes. We need four fields in our table:

  • id
  • title
  • created
  • data

The created field refers to when the note was created, and is stored in seconds (in UNIX time, which is the number of seconds that have elapsed since Jan 1, 1970). The data field will be a BLOB (Binary Large Object), so that we can fit in just about anything. We’ll make our id field an INTEGER PRIMARY KEY, our title will be a TEXT, and the created field will be an INTEGER (“NUMERIC” in SQLite Database Browser).

The theory behind using SQL databases in AIR applications is beyond the scope of this article, but Adobe provides some good documentation on strategies for working with SQL databases, which is definitely worth a read.

Running on AIR

Once we’ve got a base interface and a database, we’re ready to start adding actual functionality to our application.

If you haven’t done so yet, download, and unzip the code archive containing the skeleton files for this article (air1-notesdb-base.zip).

In the .zip file you’ll see the following files:

  • notes.html
  • notes_base.db
  • styles.css
  • icons/delete.png
  • lib/notes.js

Next, create a new AIR project in Aptana, and name it NotesTest. Specify the folder to which you extracted the files from the code archive as a Location. When you click Next, you’ll be presented with a dialog to enter the Application Descriptor properties. Choose an appropriate ID (I’ve used com.sitepoint.example.NotesTest), click Next again, then set your default window size to 800 x 600 pixels. Click Next again, and from the Import JavaScript Library dialog, select jQuery, and finally click Finish.

Aptana will create a few sample files for you to try out your environment; you can safely delete some of them though, including NotesTest.html, sample.css, LocalFile.txt, jquery_sample.html, and jquery_sample.js. We do need to tell Aptana that the root of our AIR application should be our new notes.html file. Open up the application.xml file and locate the following (it should be on or around line 36):

<!-- The main HTML file of the application. Required. -->  
<content>NotesTest.html</content>

Change NotesTest.html to notes.html and save the file. From the Run menu, select Run…. Select your NotesTest project in the left pane, and hit the Run button:

Figure 6. Running our Note Storage project for the first time

Your AIR application will display in its own window, complete with the prototype HTML that we created earlier. It should look something like this:

Figure 7. The first iteration of our Note Storage application

It’s a good start! Let’s make this application a bit more useful.

Menus

We’ll first put the interface in place. We’ll store our JavaScript code in lib/notes.js. Here’s what our template looks like at the moment:

// Bootstrap  
$(document).ready(function(){  
 BindEvents();  
});  
 
function BindEvents() {  
 $("#new_note_form").hide();  
 $("a.notes_list").click(HideNewNote);  
}  
 
function HideNewNote() {  
 $("#new_note_form").hide();  
}  
 
function ShowNewNote() {  
 $("#new_note_form").show();  
}

This jQuery snippet waits until the DOM is ready to be manipulated, then hides the note creation form, and binds to the click event of the Cancel link inside that same form. If you need a primer on jQuery, check out the “jQuery 101″ section of this article.

AIR provides an extensive API for generating menus from intricate data structures. All we really want, however, is a basic menu that fires JavaScript events when certain menu items are selected. With AIR 1.1, we can make use of the AIR Menu Builder framework, which allows us to define menus in XML and just load them into the menu builder for them to be magically generated. Create a new file called notes_menus.xml inside your NotesTest project that looks like this:

<?xml version="1.0" encoding="utf-8"?>  
<root>  
 <menuitem label="File">  
   <menuitem label="_New Note" onSelect="ShowNewNote" />  
 </menuitem>  
</root>

Using XML, we can define each menu item as a menuitem node. If a node contains children (additional menuitems), then the parent node becomes a submenu.

Save notes_menus.xml and open up the file lib/notes.js. Let’s create a new function, CreateMenus, that contains the following code:

// UI  
function CreateMenus() {  
 var menu = air.ui.Menu.createFromXML("notes_menus.xml");  
 air.ui.Menu.setAsMenu(menu);  
}

Place this function right after our $(document).ready function. We’ll then make a call to it at the end of that function, as I’ve done here:

// Bootstrap  
$(document).ready(function(){  
 BindEvents();  
 CreateMenus();  
});

Now when we run our AIR application again, our menus appear in the application’s window (or in the OS X menu bar on a Mac).

Note: Aptana’s Time-saving Keyboard Shortcuts

Press Ctrl+F11 (Cmd-Shift-F11 on the Mac) to run your AIR application using the same configuration as the last time it ran.

Figure 8. An example menu item created by the AIR Menu Builder

There are a number of possible attributes that each of our menu nodes can take, but the only one we’re concerned with is onSelect. When a menu item is selected, this property defines which JavaScript function should be fired:

<menuitem label="_New Note" onSelect="ShowNewNote" />  
 
function ShowNewNote() { ...

The XML snippet above defines that, when New Note is selected (either from a mouse click or via keyboard input), the ShowNewNote JavaScript function should be invoked.

We can also add accessibility features to our menus – for example, we can add keyboard shortcuts, Keyboard shortcuts are specified using the underscore character, as I’ve done in the label property:

<menuitem label="_New Note" onSelect="ShowNewNote" />
Exploring the File System

Now let’s look at storing and manipulating our data. We’re going to look at databases and file system APIs together, as AIR’s implementation of local SQL databases relies heavily on both. SQLite databases are independent files, and instead of being accessed through a database server, they are manipulated directly by the application. In this case, AIR plays the role of database server for us. However, to get started, we first need to tell AIR where to find the database file by specifying a .db file.

All of the file system APIs live under the air.File namespace. There are two important predefined paths for our AIR application: the application directory and application storage directory.

  • The application directory is the folder in which the application itself is stored.
  • The application storage directory is a folder created by AIR for this particular application (and this application only) to store data.

The application directory is actually read-only, so if we want to edit our database, we’ll need to place the database file in the storage directory. As we’re distributing our skeleton database as part of the application, we’ll need to copy our database template (notes_base.db) to the storage directory on-the-fly.

Connecting to the Database

To open a connection to the database, we use the following code:

var db = new air.SQLConnection();  
try  
{  
 db.open(air.File.applicationStorageDirectory.resolvePath("notes.db"));  
}  
catch (error)  
{  
 air.trace("DB error:", error.message);  
 air.trace("Details:", error.details);  
}

Note: Printing Trace Statements in AIR

To print to the AIR debug console, or to the console view in Aptana, use the function air.trace().

In the code above, we use the resolvePath function to open a file called notes.db in the application storage directory. However, before we can access this file, we first need to make a copy of our template SQLite database, contained in the notes_base.db file in our application directory. The following code achieves this:

var dbFile = air.File.applicationStorageDirectory.resolvePath("notes.db");  
 
//In production, uncomment the if block to maintain the database.  
//if (!dbFile.exists) {  
 var dbTemplate =  
air.File.applicationDirectory.resolvePath("notes_base.db");  
 dbTemplate.copyTo(dbFile, true);    
//}

When using resolvePath to resolve a path on the file system, it’s not necessary to first check whether the file exists – the exists property will indicate whether or not the file can be found. As you can see above, we check this property in the production-ready version of our app. It’s useful in testing mode to revert to our database template each time the application is launched – but we certainly wouldn’t want to replace the database with a blank template when dealing with real data!

Let’s pull all of these pieces together into a single function called SetupDB, which looks like this:

var db = new air.SQLConnection();  
function SetupDB() {  
 var dbFile = air.File.applicationStorageDirectory.resolvePath("notes.db");  
   
 //In production, uncomment the if block to maintain the database.  
 //if (!dbFile.exists) {  
   var dbTemplate = air.File.applicationDirectory.resolvePath("notes_base.db");  
   dbTemplate.copyTo(dbFile, true);    
 //}  
   
 try  
 {  
   db.open(dbFile);  
 }  
 catch (error)  
 {  
   air.trace("DB error:", error.message);  
   air.trace("Details:", error.details);  
 }  
}

Place this function at the end of your notes.js file, and while you’re there, add a call to this new method from within our $(document).ready() function (which lives at the top of the file).

Working with Databases

Now that we’ve set up a database, we’re ready to start pulling existing notes from the database.

Just as an SQLConnection object maintains a connection to a database, an SQLStatement maintains a particular query to the database. These SQLStatement instances are linked to a particular connection through their SQLConnection property. SQLStatement objects support prepared statements, and a few different methods for retrieving the result of each query; they can also be reused. Check out the reference page for SQLStatement for further details.

Let’s create a GetNotes function to fetch our notes. Here’s the code, which should be added to the end of your notes.js file.

function GetNotes() {   
 dbQuery = new air.SQLStatement();  
 dbQuery.sqlConnection = db;  
 dbQuery.text = "SELECT id,title,created,data FROM notes";  
   
 try {  
   dbQuery.execute();  
 } catch (error) {  
   air.trace("Error retrieving notes from DB:", error);  
   air.trace(error.message);  
   return;  
 }  
   
 return dbQuery.getResult();  
}

This code is reasonably straightforward; we first create a new SQL statement and assign its SQLConnection property to our existing database connection. We then set the actual query text and run the query, catching any potential errors along the way. Finally, we return the result as a SQLResult object.

Let’s use the Introspector to examine the data type that this function returns. Run your AIR application (Run > Run…), then hit F12 to launch the Introspector, and execute “GetNotes()” from the JavaScript console. Here’s the output:

Figure 9. Running the GetNotes function in the Introspector console

The number that’s important to us here is the value “0” that I’ve highlighted in the figure above – it represents the first item in our database table. The data property, in fact, contains all of the rows returned by a database query, beginning at index number 0. Each query also provides us with a length property, which tells us how many rows were returned. Knowing this information, we can now construct a simple routine to retrieve each note from the database, and print it to the page:

var notes = GetNotes();   
$("#notes").empty();  
 
var numRecords = notes.data.length;  
 
for (i=0;i<numRecords;i++) {  
 dateObj = new Date(notes.data[i].created);  
 time = dateObj.getFullYear()+"-"+  
      String("0"+dateObj.getMonth()).slice(-2)+"-"+  
      String("0"+dateObj.getDate()).slice(-2)+" "+  
      String("0"+dateObj.getHours()).slice(-2)+":"+  
      String("0"+dateObj.getMinutes()).slice(-2);  
 $("<li/>").append('<span class="note_time">'+time+  
           '<a href="#del/'+notes.data[i].id+'">'+  
           '<img src="icons/delete.png"/></a></span>')  
       .append('<span class="note_title">'+notes.data[i].title)    
       .append('<pre>'+notes.data[i].data.'</pre>')  
       .appendTo("#notes");  
}

This routine inspects each record, checking when the note was created and performing some basic formatting in preparation for displaying the note. We create a new list item for each record, and place each note at the end of the list. We’re achieving all this by relying fairly heavily on jQuery here, but this could well have been achieved using standard DOM calls.

Deleting Notes

OK, so we can display our notes, but we’re still missing some functionality – namely, adding and deleting notes. Let’s enable that pretty, red Delete button!

We need to bind a new function to each of the Delete buttons. Note that the actual Delete images will be surrounded by links, each of which has its own unique identifier. We can leverage this fact to determine which note we want to delete. Once we’ve written the code to do this, the code is almost identical to our GetNotes Function.

It’s time to add a new method, ListNotes, to the bottom of your notes.js file:

function ListNotes() {   
 var notes = GetNotes();  
 $("#notes").empty();  
   
 var numRecords = notes.data.length;  
   
 for (i=0;i<numRecords;i++) {  
   dateObj = new Date(notes.data[i].created);  
   time = dateObj.getFullYear()+"-"+  
        String("0"+dateObj.getMonth()).slice(-2)+"-"+  
        String("0"+dateObj.getDate()).slice(-2)+" "+  
        String("0"+dateObj.getHours()).slice(-2)+":"+  
        String("0"+dateObj.getMinutes()).slice(-2);  
   $("<li/>").append('<span class="note_time">'+time+  
             '<a href="#del/'+notes.data[i].id+'">'+  
             '<img src="icons/delete.png"/></a></span>')  
         .append('<span class="note_title">'+unescape(notes.data[i].title))  
         .append('<pre>'+unescape(notes.data[i].data)+'</pre>')  
         .appendTo("#notes");  
 }  
   
 $(".note_time a").click(function(){  
   var currHash = $(this).attr("href").split('/');  
   var id = currHash[1];  
     
   var dbQuery = new air.SQLStatement();  
   dbQuery.sqlConnection = db;  
   dbQuery.text = "DELETE FROM notes WHERE id=" + id;  
     
   try {  
     dbQuery.execute();  
   } catch (error) {  
     air.trace("Error deleting note from DB:", error);  
     air.trace(error.message);  
     return;  
   }  
     
   ListNotes();  
 });  
}

We also need to call ListNotes from our $(document).ready method at the start of our notes.js file.

Adding New Notes

We have just one more database operation to flesh out: allowing our users to add new notes. This functionality is quite similar to both of the previous database operations, with two exceptions:

  1. We need to fetch the values from the New Note form (and work out the current time in UNIX time), and
  2. We need to regenerate the list of notes, once we’re done.

Here’s the code, ready to copy into the end of our notes.js file:

function AddNote() {   
 var title = escape($("#title").val());  
 var now = new Date();  
 var created = Date.parse(now.toString());  
 var data = escape($("#data").val());  
   
 dbQuery = new air.SQLStatement();  
 dbQuery.sqlConnection = db;  
 dbQuery.text  = "INSERT INTO notes (title,created,data)";  
 dbQuery.text += "VALUES ('"+title+"',"+created+",'"+data+"')";  
   
 try {  
   dbQuery.execute();  
 } catch (error) {  
   air.trace("Error inserting new record into database:", error);  
   air.trace(error.message);  
 }  
   
 HideNewNote();  
 ListNotes();  
}

The first few lines of this code fetch the values from each field. Date.parse (a static date function) will convert a date string into a UNIX time value. When we construct our database query, we add the user data from these form fields. There’s just one minor limitation – we need to escape our string. Failing to do so could potentially result in a malformed query string, hence the escape calls that you see there.

We also need to bind this new function to the Create Note button. This is very easy using jQuery – just add the following line, shown in bold, to your BindEvents function:

function BindEvents() {   
 $("#new_note_form").hide();  
 $("a.notes_list").click(HideNewNote);  
 $("#new_note_btn").click(AddNote);  
}

Working with the Clipboard

When creating notes, a user may have content stored in their operating system’s clipboard that they want to insert into our application as a note. AIR provides access to the clipboard quite easily, so let’s check it when we display the New Note form. Replace your ShowNewNote function with the following lines:

function ShowNewNote() {    
 if (air.Clipboard.generalClipboard.hasFormat(    
   air.ClipboardFormats.TEXT_FORMAT)){    
     $("#data").val(air.Clipboard.generalClipboard.getData(    
       air.ClipboardFormats.TEXT_FORMAT));    
 }    
 $("#new_note_form").show();    
}

In the above code snippet, lines 2–4, check whether there is any plaintext data on the clipboard. If there is, we copy that data into the main content area of our New Note form. It would be possible to extend this functionality to include other data formats as well, including rich text and HTML – check out the clipboard data formats in the AIR documentation if you’d like to experiment some more.

If we wanted to take this application even further, we could offer a simple “copy” function for each individual note. This functionality is already supported by most major browsers, but AIR goes one step further by allowing us to directly manipulate the contents of the general clipboard. If you’re interested in exploring this functionality, the official AIR documentation does a good job of explaining how to read from and write to the system clipboard, and includes some great code examples.

Closing an Application

Chances are we’ll want to let our user close our note storage application easily. Let’s create a File > Exit menu item, to give our application a touch of desktop familiarity. First, open up notes_menus.xml and add the line in bold below:

<?xml version="1.0" encoding="utf-8"?>    
<root>    
 <menuitem label="File">    
   <menuitem label="_New Note" onSelect="ShowNewNote" />    
   <menuitem label="E_xit" onSelect="QuitNotes" />    
 </menuitem>    
</root>

We’ve set the mnemonic on the x of “Exit” – common practice in Windows desktop apps.

We can close the application by calling the method air.NativeApplication.nativeApplication.exit. However, AIR provides a sophisticated event system designed to deal with more complex operations that applications may need to perform. One of these operations is to prevent default actions. Given that we may want to temporarily disable the capacity of our user to exit the application – for example, during an asynchronous delete-all-notes database operation – we’ll use this approach instead.

There’s a preventDefault method in air.Event that will, not surprisingly, prevent AIR from executing an event’s default action. In our case, this event is called air.Event.EXITING, and the routines throughout our application should all take this approach. We can check if the default exit action is being prevented by another area of the application using a simple procedure:

function QuitNotes() {    
 var event = new air.Event(air.Event.EXITING, false, true);    
 air.NativeApplication.nativeApplication.dispatchEvent(event);    
 if (!event.isDefaultPrevented()) {    
   air.NativeApplication.nativeApplication.exit();    
 }    
}

Place this function at the end of your notes.js file, and your user will no longer be able to terminate the application when it’s in the middle of something important. The AIR docs provide more information about handling application termination.

Finishing up

That’s it for our note storage application! If you hit any hurdles along the way, you can download the final project files to compare with your own. To use these files, simply import them into a new AIR project in Aptana (select File > Import > Archive File). Aptana also allows you to easily package the application as an AIR file for deployment. Click the air button on your Aptana toolbar. In the Adobe AIR Package Exporter dialog that appears, just confirm that the right project is selected, and that the application descriptor is called application.xml. To minimize file size, feel free to remove the AIR localizer and the excess jQuery files, then click Finish and an AIR package will be created for you. Here’s what your completed package should look like.

Further reading

AIR is a very powerful platform, and by using familiar web technologies, it has an extremely low barrier of entry. In this tutorial, we used our existing JavaScript skills and the powerful jQuery library to create a practical AIR application. Along the way, we looked at how AIR implements menu items, writes to a database, manages events, and more.

If this article has excited you enough to develop more with Adobe AIR, you’re in luck – next week we’ll be publishing a follow-up article, which builds on the principles we learned here.. If you can’t wait, however, be sure to visit the official AIR guide on adobe.com, Developing HTML/AJAX applications on Adobe AIR. This resource contains a number of comprehensive guides covering all the major areas of AIR development, as well as links to other great tutorials. And once you’ve exhausted the documentation, head to the Adobe AIR Developer Center for HTML and Ajax for some terrific, focused tutorials. So what are you waiting for? Get running on AIR!

Quiz Yourself

Test your understanding of this article with a short quiz, and receive a FREE copy of the pocket guide, Adobe AIR For JavaScript Developers, delivered to your door for FREE, thanks to Adobe Systems. This offer is only available to the first 100 people, so get in quick (if you miss out, you can also download the book in PDF format for free).

Take the quiz!

Free book: Jump Start HTML5 Basics

Grab a free copy of one our latest ebooks! Packed with hints and tips on HTML5's most powerful new features.

  • Boumaig

    Nice tutorial thank you.