Click here to Skip to main content
12,448,164 members (49,406 online)
Click here to Skip to main content
Add your own
alternative version

Stats

16.2K views
258 downloads
46 bookmarked
Posted

A Simple Single Page Application Framework

, 4 Nov 2015 CPOL
Rate this:
Please Sign up or sign in to vote.
85 lines of Javascript, 1441 bytes minified

Introduction

My motivation for this article is that I wanted:

  1. to learn the nitty gritty of how SPA's work
  2. a framework that isn't in bed with a lot of other cruft, like MVC, data binding, etc.
  3. something that was simple to use -- the SPA frameworks I've looked seems ridiculously complicated, and I wanted to know if it needs to be that complicated

Unlike some homebrew examples of SPA, this code does not take the approach of hiding and showing containers like div tags.  The implementation presented here:

  • dynamically loads HTML and Javascript content from the server
  • automatically unloads the previous page's Javascript
  • implements a simple router for navigating to any SPA page
  • implements browser navbar history

Note that all of the Javascript code here relies only on jQuery.

Running the Demo

I've included a simple web server so that you can run the demo "out of the box."  Just fire up the SimpleServer application and navigate to localhost in your browser.  Of course, I'm assuming that port 80 is free and there are sometimes issues with adding a listener to http://localhost/ but hopefully you won't encounter any problems.  The web server code is posted at the end of this article.

A Simple Example of Changing HTML

Let's start with a simple concept -- how to change HTML on the fly.  This is of course a requirement of the SPA, that it either already has all the HTML it needs as the person navigates around the website, or at most, it queries the server for just what it needs.

So, let's start with a simple example illustrating how a region of HTML can be dynamically changed:

<!DOCTYPE html>
<html lang="en">
<head>
  <title>Index</title>
</head>
<body>
  <script type="text/javascript">
    var state = 0
    $(document).ready(function () {
      var hello = "<div>Hello World!</div>"
      var goodbye = "<div>Goodbye Cruel World</div>"

      $("#region1").html(hello)

      $("#switcher").click(function () {
        if (state == 0) {
          $("#region1").html(goodbye)
          state = 1
        } else {
          $("#region1").html(hello)
          state = 0
        }
      })
    })
  </script>

  <div id="region1"></div>

  <div id="region2">
    <button id="switcher">Switch HTML</button>
  </div>
</body>
</html>

As should be rather obvious from the above markup, the contents of the div "region1" are dynamically updated.  Here's a JSFiddle demonstration.

Discussed Later:

  • update specific regions of the document
  • validate authentication/authorization to ensure the user is authorized to view the page
  • use hash tags to create a browser history for changes

A Simple Example of Changing Javascript

OK, not so simple.  But the idea is that, like with the HTML, we want to be able to load (and unload) whatever Javascript the page requires.  While we could avoid this by loading once all the Javascript that the site requires, it's a rather ridiculous assumption that this may not be 10's or 100's of megabytes of Javascript (god forbid.)

Using Eval()

There seems to be several methods of doing this, the simplest is using eval():

<script type="text/javascript">
  var state = 0
  $(document).ready(function () {
    var hello = "<div>Hello World!</div>"
    var goodbye = "<div>Goodbye Cruel World</div>"

    var jsHello = "$('#region1').html(hello);state = 0"
    var jsGoodbye = "$('#region1').html(goodbye);state = 1"

    eval(jsHello)

    $('#switcher').click(function () {
      if (state == 0) {
        eval(jsGoodbye)
      } else {
        eval(jsHello)
      }
    })
  })
</script

OK, that's interesting, and you can see it running here.  What would be more interesting though it to actually re-wire the button click event handler, effectively eliminating the separate state variable:

$(document).ready(function () {
  var hello = "<div>Hello World!</div>"
  var goodbye = "<div>Goodbye Cruel World</div>"

  var jsHello = "\
    $('#region1').html(hello);\
    $('#switcher').off('click');\
    $('#switcher').click(function () {eval(jsGoodbye)});"

  var jsGoodbye = "\
    $('#region1').html(goodbye);\
    $('#switcher').off('click');\
    $('#switcher').click(function () {eval(jsHello)});"

  eval(jsHello)
})

You will also note that we are unbinding the previous event handler because the behavior of the button click has now changed.

NOTE: Selective binding/unbinding of events can be achieved with jQuery's on/off functions and using namespaces, as discussed here.

IMPORTANT: When dealing with SPA's, it is very important to unbind event handlers in order to prevent memory leaks and other strange behaviors.

A JSFiddle for the above example is here.  What's really interesting about this example is that the Javascript can be loaded from an AJAX call. 

Adding/Removing Script Elements

The above approach potentially has a memory leak, especially when using jQuery's loadScript() method because as pages are changed, Javascript is constantly loaded and executed and never removed. 

Other methods, as discussed here and here, involve using document.write or dynamically adding/removing script elements from head.  I'll borrow from the javascriptkit.com discussion on loading and removing external Javascript for the following demonstration (this part requires a server):

function loadjs(filename) {
  var fileref = document.createElement('script')
  fileref.setAttribute("type", "text/javascript")
  fileref.setAttribute("src", filename)
  $("head")[0].appendChild(fileref)
}

function unloadjs(filename) {
  var targetElement = "script"
  var targetAttr = "src"
  var allSuspects = $(targetElement)
  for (var i = allSuspects.length; i >= 0; i--) {
    if (allSuspects[i] &&
      allSuspects[i].getAttribute(targetAttr) != null &&
      allSuspects[i].getAttribute(targetAttr).indexOf(filename) != -1) {
      allSuspects[i].parentNode.removeChild(allSuspects[i])
    }
  }
}

Observation of memory usage (using Chrome's task manager -- press Shift+Esc on the Chrome browser) definitely shows that using loadScript will result in memory leaks (the memory the page requires keeps growing) while the above approach, appending and removing Javascript files from head, does not.  This is not to say that loadScript causes memory leaks, it's simply the wrong approach.

Describing What We Need From the Server

To create something a bit more sophisticated than embedding HTML and Javascript into strings, we need a function that describes what we need from the server for the page to render and also how to clean up our current page's events.  Here's a prototype of what this function requires from us:

function loadRegion(pageName, containerId, htmlFile, loadJavascriptFiles, unloadJavascriptFiles, fncUnload) { }

This includes:

  • the new page name (used later so that browser history can work)
  • the container ID (some container, like a div, into which the HTML will be replaced)
  • the actual HTML file from the server (or other location) that defines the HTML content
  • a collection of zero or more javascript files that will be appended to head
  • a collection of zero or more javascript files to be removed as the page no longer needs them
  • the function that unloads (unbinds) the current page

IMPORTANT: Querying the server for the HTML file is one method of performing authentication/authorization.

Implementation

The implementation, leveraging jQuery, is trivial:

function loadRegion(pageName, containerId, htmlFile, loadJavascriptFiles, unloadJavascriptFiles, fncUnload) {
  fncUnload()
  $('#' + containerId).load(htmlFile)
  unloadJavascriptFiles.map(function (jsFile) {
    unloadjs(jsFile)
  })
  loadJavascriptFiles.map(function (jsFile) {
    loadjs(jsFile)
  })
}

Demo

Is it really that simple?  Yes it is.  Here's our new "master" page and the nicely separated out (guess what, we've just separated the HTML and Javascript into a View and Controller!) files (note I like to use the extension "spa" to differentiate my SPA files):

loadRegion.html (just the Javascript part, the regions stayed the same):

<script type="text/javascript">
  $(document).ready(function () {
    loadRegion('hello', 'region1', 'helloworld.spa', ['hello.js'], [], function() {$('#switcher').off('click')})
  })
  ...
}
</script

Our initial document loads hello.js and has no Javascript to unload when the page changes.

helloWorld.spa:

<div>Hello World!</div>

goodbyeWorld.spa:

<div>Goodbye Cruel World</div>

hello.js:

function hello() {
  loadRegion('hello', 
    'region1', 
    'helloworld.spa', 
    ['goodbye.js'], 
    ['hello.js'], 
    function () { $('#switcher').off('click') })
}
$('#switcher').click(function () { hello() });

goodbye.js:

function goodbye() {
  loadRegion('goodbye', 
    'region1', 
    'goodbyeworld.spa', 
    ['hello.js'], 
    ['goodbye.js'], 
    function () { $('#switcher').off('click') })
}
$('#switcher').click(function () { goodbye() });

The beauty of this approach (at least I think so) is that I can have fine-grain control over what regions are updated as required by the particular SPA.  Of course, it looks a bit unwieldy, which we'll improve later.

Browser History

This involves two parts: being able to tell the browser that we've changed the site URI and being able to navigate to the correct SPA if given a specific URI.  The latter requires a client-side router table!

First, we'll add this one line to our loadRegion function:

setCurrentPage(pageName)

Which sets the global variable currentPage so we can track what page has been set programmatically vs. what page has been requested by user input on the address bar:

function setCurrentPage(pageName) {
  currentPage = pageName
  window.location.hash = pageName
}

We now have a locatable page on the address bar:

<a href="http://localhost/loadregion#hello">http://localhost/loadregion#hello</a>
<a href="http://localhost/loadregion#goodbye">http://localhost/loadregion#goodbye</a> 

We can also hook hash change events and process rendering the correct SPA:

$(window).on('hashchange', function () {
  render(window.location.hash);
});

function render(hash) {
  if ('#' + currentPage != hash) {
    ...
  }
}

Now for the Complexity

Regardless of what page we're on, the user can manually enter an address to go to a completely different page.  To properly clean up the existing page, we need to know where we just were.  Both knowing where we were and where we're going requires a client-side router table:

var router = [
  {
    pageName: '', region: 'region1', spa: 'helloworld.spa', load: ['hello.js'], unbind: function () {}
  },
  {
    pageName: 'hello', region: 'region1', spa: 'helloworld.spa', load: ['hello.js'], unbind: function () { $('#switcher').off('click') }
  },
  {
    pageName: 'goodbye', region: 'region1', spa: 'goodbyeworld.spa', load: ['goodbye.js'], unbind: function () { $('#switcher').off('click') }
  }
]

Notice how each page knows how to unbind itself.  We also no longer have to specify the Javascript to unload because we assume that what the page loaded needs to be unloaded before the next page renders (we don't handle common Javascript code in this iteration.)  We can now implement the render and loadPage functions and refactor the loadRegion function:

function render(hash) {
  if ('#' + currentPage != hash) {
    loadPage(hash.substring(1))
  }
}

function loadPage(pageName) {
  var oldPage = $.grep(router, function (r) { return r.pageName == currentPage })
  var newPage = $.grep(router, function (r) { return r.pageName == pageName })

  if (newPage.length == 1) {
    if (oldPage.length == 1) {
      oldPage[0].unbind()
      oldPage[0].js.map(function (jsFile) {
        unloadjs(jsFile)
      })
    }

    loadRegion(newPage[0])
    setCurrentPage(newPage[0].pageName)
  }
}

function loadRegion(pageInfo) {
  $('#' + pageInfo.region).load(pageInfo.spa)

  pageInfo.js.map(function (jsFile) {
    loadjs(jsFile)
  })
}

Our Javascript specific to each SPA is now even simpler:

hello.js:

$('#switcher').click(function () { loadPage('goodbye') });

goodbye.js:

$('#switcher').click(function () { loadPage('hello') });

That's really all there is to it.  We can support multiple regions by refactoring the page loader/unloader to iterate through the matching page collection:

function loadPage(pageName) {
  var oldPages = $.grep(router, function (r) { return r.pageName == currentPage })
  var newPages = $.grep(router, function (r) { return r.pageName == pageName })

  if (newPages.length > 0) {
    if (oldPages.length > 0) {
      oldPages.map(function(oldPage) {
        oldPage.unbind()
        oldPage.js.map(function (jsFile) {
          unloadjs(jsFile)
        })
      })
    }

    newPages.map(function(newPage) {
      loadRegion(newPage)
    })

    setCurrentPage(newPages[0].pageName)
  }
}

This let's us define different regions that a page updates, like this:

{
  pageName: 'hello', region: 'region1', spa: 'helloworld.spa', js: ['hello.js'], unbind: function () { $('#switcher').off('click') }
},
{
  pageName: 'hello', region: 'region3', spa: 'hellospa.spa', js: [], unbind: function () { }
},

Here the "hello" page updates div region1 and region3 with different HTML.  Only region1 has associated Javascript.  The demo code that you can download here on this article illustrates this feature.

Authentication / Authorization

Because the HTML is acquired from the server (rather than hidden/shown from pre-loaded containers), the server can perform session authentication checks and determine whether the user is authorized to view the content.  The niggling issue here is, what happens when the SPA page needs to be redirected because of some error (typical errors would be page not found, expired session, no authentication, not authorized.)

Page Not Found

Page not found is simple enough, and interestingly enough, it is handled now by the client-side (at least for not found hash-tag pages):

function loadPage(pageName) {
  var oldPages = $.grep(router, function (r) { return r.pageName == currentPage })
  var newPages = $.grep(router, function (r) { return r.pageName == pageName })

  if (newPages.length > 0) {
    ...
  } else {
    loadPage('pageNotFound')
  }
}

All we need to provide is a "pageNotFound" route, something like this:

{
  pageName: 'pageNotFound', region: 'region1', spa: 'pageNotFound.spa', js: [], unbind: function () { }
}

and of course, implement the "pageNotFound.spa" HTML fragment:

<div>We're sorry, the page you're looking for doesn't exist.</div>

Expired / Not Permitted

We can use jQuery's load function's completion call to test for other server errors, which of course takes some coordination on the part of our server-side code to produce the desired error:

function loadRegion(pageInfo) {
  $('#' + pageInfo.region).load(pageInfo.spa, responseHandler)
  ...
}
function responseHandler(responseText, responseStatus, jqXHR) {
  if (responseStatus == 'error') {
    if (responseText == 'Authentication Required') {
      loadPage('authenticationRequired')
    }
  }
}

The "authenticationRequired" page requires a  client-side route:

{
  pageName: 'authenticationRequired', region: 'region1', spa: 'notAuthorized.spa', js: [], unbind: function () { }
},

and in my example (not necessarily the best approach), it requires knowing the response text that server issues.  In my server, I have these two strings:

switch (session.GetState(context))
{
  case SessionState.New:
    proc.ProcessInstance<WebServerMembrane, StringResponse>(r =>
      {
        r.Context = context;
        r.Message = "Authentication Required";
        r.StatusCode = 500;
      });

   break;

  case SessionState.Expired:
    proc.ProcessInstance<WebServerMembrane, StringResponse>(r =>
      {
        r.Context = context;
        r.Message = "Session Expired";
        r.StatusCode = 500;
      });

  break;
}

thus my particular way of demonstrating authentication/expiration issues (and no, I'm not going to go into the details of what a WebServerMembrane is, that's for another article.)

Contexts

Ideally, if the user is authorized to access a particular URL path (which defines a context) then every page in that context should be permissible to view as well.   So it's important not to go nutso with SPA's -- your entire site should not become one single SPA.  Break your site up into contexts, such as administration, reporting, user data management, and so forth.

A Problem With LoadRegion

While I was implementing my SPA solution for an actual website, I discovered that there is an issue with loading the HTML and the Javascript.  This code:

function loadRegion(pageInfo) {
  $('#' + pageInfo.region).load(pageInfo.spa, , responseHandler)
  pageInfo.js.map(function (jsFile) {
    Clifton.Spa.loadjs(jsFile)
  })
}

results in a race condition: the HTML may or may not be loaded by the time the Javascript is processed.  To fix this, we have to guarantee that the Javascript loads after the HTML, ensuring that the HTML exists before the Javascript does any processing with it:

function loadRegion(pageInfo) {
  $('#' + pageInfo.region).load(pageInfo.spa, function (responseText, responseStatus, jqXHR) {
    pageInfo.js.map(function (jsFile) {
      Clifton.Spa.loadjs(jsFile)
    })
  })
}

Here the Javascript is loaded synchronously after the HTML is loaded.  I discovered this when working with client-side dynamically created HTML content -- the Javascript was trying to stuff content into an HTML element that did not yet exist!  One possible enhancement would be to specify whether the Javascript for a particular page can be loaded synchronously or asynchronously.

The Web Server Code

A very simple server:

using System;
using System.IO;
using System.Net;
using System.Text;
using System.Threading.Tasks;

namespace SimpleServer
{
  class Program
  {
    static HttpListener listener;

    static void Main(string[] args)
    {
      listener = new HttpListener();
      listener.Prefixes.Add("http://localhost/");
      listener.Start();
      Task.Run(() => WaitForConnection(listener));
      Console.WriteLine("Press ENTER to exit the server.");
      Console.ReadLine();
    }

    static void WaitForConnection(object objListener)
    {
      HttpListener listener = (HttpListener)objListener;

      while (true)
      {
        HttpListenerContext context = listener.GetContext();
        string filename = context.Request.RawUrl.Substring(1); // strip off leading /
        Console.WriteLine(filename);

        if (filename == "")
        {
          filename = "index.html";
        }

        if (File.Exists(filename))
        {
          byte[] data = File.ReadAllBytes(filename);

          switch (Path.GetExtension(filename))
          {
            case ".js":
              context.Response.ContentType = "text/javascript";
              break;

            case ".spa":
            case ".html":
              context.Response.ContentType = "text/html";
              break;

            default:
              break;
          }

          context.Response.ContentEncoding = Encoding.UTF8;
          context.Response.ContentLength64 = data.Length;
          context.Response.OutputStream.Write(data, 0, data.Length);
          context.Response.Close();
        }
	else
        {
          context.Response.StatusCode = 500;
          context.Response.Close();
        }
      }
    }
  }
}

Lessons Learned

Writing an SPA without a framework is simple  So why is SPA complex?  Sebastian Porto's post "Lessons learnt by building Single Page Applications" is an excellent read for anyone considering moving to SPA.

What Makes SPA Complex?

What makes SPA complex is not the technology that enables you website to be a single page application, but rather, whether you're website deals correctly with all the possible transitions from one page to another.  One way to mitigate that complexity is to isolate your SPA into "context", where each context requires a full page load.  As long as the user stays within that context, your SPA transitions are manageable.  When they change context, a full page load switches the client-side routing to the new context and of course replaces all the page regions of the previous context with whatever regions the context manipulates.  As mentioned above, contexts also eases managing authentication / authorization, as the server is authenticating the context, not the individual hash pages.

SPA is an Architecture Decision, not just a Framework

While one might consider the code to support SPA to be a framework, writing an SPA website is a shift in architecture, and one that requires careful planning with regards to the full page contexts, the pages within those contexts, and the regions that are being updated within those pages.  It also involves careful consideration of error messages back to the client, client-side routing, and continual vigilance with regards to authentication/authorization to ensure that you don't accidentally expose some vital and secure part of your website.

Converting a Site to SPA may not Trivial

If you're thinking of going SPA, it's a lot easier to do so from scratch than it is to change an existing website.  If you do have to change an existing website (a situation that I'm in), I find it useful to break the site up into its logical contexts and work within one context at a time.  While I did go through the process of converting a website with some 40 or so pages to SPA, and while it became an automaton cookie cutter process:

  1. Extract the HTML and put it into its unique .spa file (purely my convention)
  2. Extract the Javascript and put it into its unique .js file
  3. Enter the route
  4. Fixup my main menu
  5. Test

And I could do all five steps in 2 minutes or so, the point is, if I'd had this in place before writing the website, I could have saved myself a couple hours.  Given that the website I was working on was fairly simple, this wasn't bad, but I can imagine that a complex site, or one that is really entangled with ASP.NET or MVC Razor (neither of which I was using), might not be so easy.

Clean Up Your Objects

I work a lot with jqWidgets, and on several of my pages, I utilize a jqxWindow popup for adding new records.  To make matters more complicated, the content of the popup window is dynamically created based on a view schema.  What I noticed was that, even though I was re-assigning the jqxWindow to the correct div element, and the div element had indeed been correctly updated with the new dynamic content, when the window popped up, it was showing the content applicable to the first SPA in which I added a record.  As it turned out, I had to add:

$('#newRecord').jqxWindow('destroy')

as the "destructor" for my current page (where #newRecord is the ID for the jqxWindow popup div.)

This very directly demonstrated to me the hidden complexities in writing an SPA - it cannot be overemphasized that you need to track your event bindings and when using a third party library like jqWidgets, make sure you are correctly unbinding objects.  While technically I consider this a jqWidgets bug, it is on the other hand a poignant demonstration of the potential gotcha's in writing an SPA.

Don't Do Page Redirects

On the server side, don't do page redirects.  This will reload the entire page, defeating the purpose of the SPA injecting only the page-specific Javascript and HTML.

Don't Use Form Submit

The corollary to not doing page redirects is, don't use form submit.  When submitting form data, you'll typically need to rewrite it as an AJAX call and handle success/error/failure returns.  Form submission is often implemented on the server-side with an ending page redirect, either to some other "ok" page, or an error page.  If you have an existing website with a lot of forms where you'd like to convert the pages to SPA, consider using the jQuery Form Plugin.  I would have to say that using jQuery Form saved me a lot of time on the Javascript side, but I still had to fuss with both server-side (removing the redirects and adding error returns) and the client-side (checking for error returns and performing the appropriate action.)  When done though, the user experience is outstanding -- fast page loads as the user goes through several registration screens.

Integration with a View Engine

I haven't tried to do this, but I imagine that taking an existing site that relies heavily on server-side rendering of a page (i.e., a view engine) before it's delivered to the browser could make life a lot more complicated.  Even starting an SPA from scratch and relying heavily on server-side rendering rather than client-side templating (using, for example, underscore or handlebars) seems overly complicated.  But as I said, I've never actually tried to work with something like Razor in the context of an SPA, so it could be a lot easier than I'm guessing it is.

Other SPA Ways

What I've demonstrated here is a simple "fetch new HTML and Javascript from the server" implementation.  There are many times when an SPA look&feel can be accomplished simply by setting the visibility of an element.  That's a perfectly valid design decision if it fits well with what you need done.  And of course, there are SPA implementations built into various heavyweight frameworks, such as Backbone (see Developing Single Page Apps with Backbone.js) but I'd rather not read through fourteen chapters on the subject -- I'm not patient enough nor tolerant enough of complex solutions to simple problems.

What Have I Missed?

I have a niggling feeling that I've missed something important, especially with regards to preventing memory leaks, so if any readers spot a flaw in this approach, please let me know!

Updates

11/9/2015 - Forgot to click on "Add selected zip files to article"!  Thanks Sander for pointing this out.

 

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

Marc Clifton
United States United States
Marc is the creator of two open source projects, MyXaml, a declarative (XML) instantiation engine and the Advanced Unit Testing framework, and Interacx, a commercial n-tier RAD application suite.  Visit his website, www.marcclifton.com, where you will find many of his articles and his blog.

Marc lives in Philmont, NY.

You may also be interested in...

Pro
Pro

Comments and Discussions

 
QuestionA Problem With LoadRegion amendment Pin
Mark Farmiloe4-Jan-16 6:04
memberMark Farmiloe4-Jan-16 6:04 
Suggestionenable/disable js/css Pin
thewazz15-Dec-15 15:38
professionalthewazz15-Dec-15 15:38 
GeneralRe: enable/disable js/css Pin
Marc Clifton16-Dec-15 2:31
protectorMarc Clifton16-Dec-15 2:31 
QuestionGreat for beginners, vote 5 Pin
FriedhelmEichin24-Nov-15 4:04
memberFriedhelmEichin24-Nov-15 4:04 
QuestionVery good Marc Pin
Sacha Barber11-Nov-15 1:42
mvpSacha Barber11-Nov-15 1:42 
PraiseNice explanation! Pin
Dino Druding9-Nov-15 16:30
memberDino Druding9-Nov-15 16:30 
GeneralMy vote of 5 Pin
Halil ibrahim Kalkan8-Nov-15 20:15
memberHalil ibrahim Kalkan8-Nov-15 20:15 
GeneralMy vote of 5 Pin
Shmuel Zang6-Nov-15 1:05
memberShmuel Zang6-Nov-15 1:05 
PraiseMy vote of 5! Pin
jediYL5-Nov-15 8:31
professionaljediYL5-Nov-15 8:31 
GeneralMy vote of 5 Pin
Santhk5-Nov-15 0:00
professionalSanthk5-Nov-15 0:00 
GeneralMy vote of 5 Pin
D V L4-Nov-15 23:54
professionalD V L4-Nov-15 23:54 
GeneralMy vote of 5 Pin
Humayun Kabir Mamun4-Nov-15 22:29
memberHumayun Kabir Mamun4-Nov-15 22:29 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

| Advertise | Privacy | Terms of Use | Mobile
Web02 | 2.8.160811.3 | Last Updated 4 Nov 2015
Article Copyright 2015 by Marc Clifton
Everything else Copyright © CodeProject, 1999-2016
Layout: fixed | fluid