JavaScript Image Gallery

17 November 2008

Permalink: http://douggreenall.co.uk/site/?p=528

So here we are then - time for another blog post. Today I'm going to be writing about an image gallery I've put together, based on a request from Dilip in the comments of the carousel. I originally intended to have this done a couple of weeks ago but alas, I've just been too busy up until now. Still though, better late than never eh? Okay, I'll stop rambling and get on with it - let's have a look at a working example:

Scroll LeftScroll Right
Press and hold to drag images. Click an image to open for viewing.

You can move the images by either clicking and dragging them or using the adjacent arrow controls. You can also throw the images in a similar manner to the carousel and they'll decelerate to a complete stop. Additionally, you can click an individual image when stationary and a larger copy will open for viewing. The gallery is on a loop, so when it reaches the final image in the collection it'll move back to the beginning and cycle all of the images again - what's more, although there are 7 images in the demonstration above, during the main sliding animation it only actually moves a single layer at a time to display the contents - this is true no matter how many images you have in the gallery so in terms of performance, it scales pretty well.

So let's have a look at how it works then - basically we have a layer containing another layer that moves left and right. This internal layer then contains individual elements for each image we wish display, whose contents are updated as the distance travelled increases - you know, I think a diagram is in order:

Diagram showing the layer structure of the gallery

Here, the internal layer contains the images we want to display and moves left and right through the outer layer, whose CSS overflow property is set to "hidden" (thus hiding anything outside of its CSS defined width and height). It's worth noting that we don't create an element within the internal layer for every image in the gallery - we only create enough elements to display those images that can be on-screen at a single time, that is, if we default to four images as in the working example above, we only create five image elements (one extra one to show the next item coming into view). We then swap the contents of these elements and reposition them as necessary to reduce the work that the browser has to do rendering layers dynamically. Let me illustrate with another diagram:

Diagram showing the contents movement as elements move off-screen

Okay, let's have a look at the code for the gallery - as usual we'll start with the complete listing before breaking it down and examining each part in turn:

function Gallery(ID, LayerID, MouseTracker)
{
  ///-data-//////////////////////////////////////////////////////////////////
  ///////////////////////////////////////////////////////////////////////////

  this._ID = ID;
  this._layerID = LayerID;
  this._mouseTracker = MouseTracker;

  this._interval = '';

  this._left = 0;
  this._leftTarget = 0;
  this._velocity = 0;
  this._velocityScalar = 0.9;

  this._items = new Array();
  this._itemOrder = new Array();
  this._itemSize = new Vector2D(100, 75);
  this._itemSpacing = 5;
  this._itemsDisplayed = 4;
  this._itemSum = this._itemSize._x + this._itemSpacing;

  this._moveRight = false;

  this._innerLayer = null;

  this._leftImage = null;
  this._leftImageOn = new Image();
  this._leftImageOff = new Image();

  this._rightImage = null;
  this._rightImageOn = new Image();
  this._rightImageOff = new Image();

  this._leftImageOn.src = 'Content/Posts/528/lefton.gif';
  this._leftImageOff.src = 'Content/Posts/528/leftoff.gif';

  this._rightImageOn.src = 'Content/Posts/528/righton.gif';
  this._rightImageOff.src = 'Content/Posts/528/rightoff.gif';

  this._galleryItemContainer = new GalleryItemContainer(
      this._ID + '._galleryItemContainer',
      this._layerID + 'ItemContainer');

  ///-events-////////////////////////////////////////////////////////////////
  ///////////////////////////////////////////////////////////////////////////

  if (window.attachEvent)
  {
    window.document.attachEvent("onselectstart", function(Event) { return false; });
  }

  ///-methods-///////////////////////////////////////////////////////////////
  ///////////////////////////////////////////////////////////////////////////

  this.GetInnerLayer = function()
  {
    if (!this._innerLayer)
      this._innerLayer = wGet(this._layerID + 'Inner');

    return this._innerLayer;
  };

  this.GetLeftImage = function()
  {
    if (!this._leftImage)
      this._leftImage = wGet(this._layerID + 'Left');

    return this._leftImage;
  };

  this.GetRightImage = function()
  {
    if (!this._rightImage)
      this._rightImage = wGet(this._layerID + 'Right');

    return this._rightImage;
  };

  this.GetWidth = function()
  {
    return this._itemSum * this._itemsDisplayed - this._itemSpacing;
  };

  this.GetInnerWidth = function()
  {
    return this.GetWidth() + this._itemSum;
  };

  this.GetItemMarkup = function(Index, Left, ThumbSRC)
  {
    return '<div id="' + this._layerID + Index + '" style="' +
         'position:absolute; ' +
         'left:' + Left + 'px; ' +
         'top:0px; ' +
         'width:' + this._itemSize.GetXPxl() + '; ' +
         'height:' + this._itemSize.GetYPxl() + '; ' +
         'overflow:hidden; ' +
         'background-image:url(' + ThumbSRC + ');" ' +
         'onmousedown="JavaScript:' + this._ID + '.MouseDown(' + Index + ');">\n' +
         '<div id="' + this._layerID + Index + 'Overlay" style="' +
         'position:absolute; ' +
         'left:0px; top:0px; ' +
         'width:100%; height:100%; ' +
         'background-image:url(Content/Posts/528/frame.png);"></div>\n' +
       '</div>\n';
  };

  this.AddItem = function(ImageSRC, ThumbnailSRC)
  {
    this._items[this._items.length] = new Object();
    this._items[this._items.length - 1]._image = ImageSRC;
    this._items[this._items.length - 1]._thumb = new Image();
    this._items[this._items.length - 1]._thumb.src = ThumbnailSRC;

    this._itemOrder[this._itemOrder.length] = this._itemOrder.length;
  };

  this.DrawItems = function()
  {
    var layer = wGet(this._layerID);
    if (layer)
    {
      layer.style.width = this.GetWidth() + 'px';
      layer.style.height = this._itemSize.GetYPxl();
      layer.style.MozUserSelect = 'none';
      layer.style.KhtmlUserSelect = 'none';

      var markup = '<div id="' + this._layerID + 'Inner" style="' +
          'position:absolute; left:0px; top:0px; ' +
          'width:' + this.GetInnerWidth() + 'px; ' +
          'height:' + this._itemSize.GetYPxl() + ';">\n';

      var left = 0;

      for (var count = 0; count < this._items.length && count < this._itemsDisplayed + 1; ++count)
      {
        markup += this.GetItemMarkup(count, left, this._items[count]._thumb.src);
        left += this._itemSum;
      }

      markup += '</div>\n';


      layer.innerHTML = markup;

      for (var count = 0; count < this._items.length && count < this._itemsDisplayed + 1; ++count)
        IE6PNGBackgroundByID(this._layerID + count + 'Overlay');

      this._galleryItemContainer.FixBackground();
      this._galleryItemContainer.FixCloseButton();
    }
  };

  this.DockItems = function(ItemIndex)
  {
    if (this._mouseTracker._acceleration.Length() > 0)
    {
      this._leftTarget = Math.floor(this._velocity / 10) * this._itemSum;
    }
    else
    {
      if (Math.round(this._left) != 0 && Math.round(this._left) != -this._itemSum)
      {
        if (this._moveRight)
        {
          this._leftTarget = 0;
          this._velocity = 10;
        }
        else
        {
          this._leftTarget = -this._itemSum;
          this._velocity = -10;
        }
      }
      else if (ItemIndex != null)
      {
        if (!this._items[this._itemOrder[ItemIndex]]._image.src)
        {
          var imageSRC = this._items[this._itemOrder[ItemIndex]]._image;
          this._items[this._itemOrder[ItemIndex]]._image = new Image();
          this._items[this._itemOrder[ItemIndex]]._image.src = imageSRC;
        }

        this._galleryItemContainer.SetContents(
            '<img src="' + this._items[this._itemOrder[ItemIndex]]._image.src + '" ' +
            'width="' + this._galleryItemContainer._openedSize.GetXInt() + '" ' +
            'height="' + this._galleryItemContainer._openedSize.GetYInt() + '" ' +
            'alt="" title="" style="' +
            'position:absolute; left:0px; top:0px; border:none;" />\n');

        this._galleryItemContainer.Open();
      }
    }

    this.Start();
  };

  this.Decelerate = function()
  {
    var distance = this._leftTarget - this._left;
    if (distance != 0)
    {
      if (distance > 0 && distance < (10 * this._velocity))
        this._velocity *= this._velocityScalar;
      else if (distance < 0 && distance > (10 * this._velocity))
        this._velocity *= this._velocityScalar;
    }

    if (this._velocity < 0 && this._velocity > -2)
      this._velocity = -2;
    else if (this._velocity > 0 && this._velocity < 2)
      this._velocity = 2;
  };

  this.Animate = function()
  {
    this.Decelerate();

    this._left += this._velocity;

    this.MoveInnerLayer();

    if (this._left == this._leftTarget && (this._leftTarget == 0 || this._leftTarget == -this._itemSum))
    {
      this.Stop();
    }
    else if (this._velocity == 0)
    {
      this._mouseTracker.Reset();
      this.DockItems();
    }
  };

  this.Start = function()
  {
    if (this._interval == '')
      this._interval = setInterval(this._ID + '.Animate()', 10);
  };

  this.Stop = function()
  {
    if (this._interval != '')
      clearInterval(this._interval);

    this._interval = '';
  };

  this.ShiftItems = function()
  {
    for (var count = 0; count < this._itemsDisplayed + 1; ++count)
    {
      var itemLayer = wGet(this._layerID + count);
      if (itemLayer)
        itemLayer.style.backgroundImage = 'url(' + this._items[this._itemOrder[count]]._thumb.src + ')';
    }
  };

  this.ShiftItemsLeft = function()
  {
    this._itemOrder[this._itemOrder.length] = this._itemOrder[0];
    this._itemOrder.splice(0, 1);

    this.ShiftItems();
  };

  this.ShiftItemsRight = function()
  {
    this._itemOrder.unshift(this._itemOrder[this._itemOrder.length - 1]);
    this._itemOrder.splice(this._itemOrder.length - 1, 1);

    this.ShiftItems();
  };

  this.MoveInnerLayer = function()
  {
    if (this.GetInnerLayer())
    {
      if (this._left < -this._itemSum)
      {
        this._left += this._itemSum;
        this._leftTarget += this._itemSum;
        this.ShiftItemsLeft();
      }
      else if (this._left > 0)
      {
        this._left -= this._itemSum;
        this._leftTarget -= this._itemSum;
        this.ShiftItemsRight();
      }

      if (this._velocity < 0 && this._left < this._leftTarget)
        this._left = this._leftTarget;
      else if (this._velocity > 0 && this._left > this._leftTarget)
        this._left = this._leftTarget;

      this._innerLayer.style.left = Math.round(this._left) + 'px';
    }
  };

  this.Drag = function(ItemIndex)
  {
    if (this._mouseTracker._buttonDown)
    {
      if (this._mouseTracker._offset._x < 0 && this._mouseTracker._offset._x < -this._itemSum)
      {
        this._left -= this._itemSum;
      }
      else if (this._mouseTracker._offset._x > 0 && this._mouseTracker._offset._x > this._itemSum)
      {
        this._left += this._itemSum;
      }
      else
      {
        this._left += this._mouseTracker._offset._x;
      }

      this._leftTarget = this._left;

      if (this._mouseTracker._acceleration._x > this._itemSum)
        this._velocity = this._itemSum - 1;
      else if (this._mouseTracker._acceleration._x < -this._itemSum)
        this._velocity = -(this._itemSum - 1);
      else
        this._velocity = this._mouseTracker._acceleration._x;

      if (this._mouseTracker._offset._x != 0)
        this._moveRight = (this._mouseTracker._offset._x > 0);

      this.MoveInnerLayer();

      this._mouseTracker.Reset();

      setTimeout(this._ID + '.Drag(' + ItemIndex + ')', 10);
    }
    else
    {
      this.DockItems(ItemIndex);
    }
  };

  this.ScrollLeft = function()
  {
    if (this._interval == '')
    {
      if (this._left == 0)
        this.ShiftItemsRight();

      this._moveRight = true;
      this._left = -(this._itemSum - 1);
      this._mouseTracker.Reset();
      this.DockItems();
    }
  };

  this.ScrollRight = function()
  {
    if (this._interval == '')
    {
      if (this._left == -this._itemSum)
        this.ShiftItemsLeft();

      this._moveRight = false;
      this._left = -1;
      this._mouseTracker.Reset();
      this.DockItems();
    }
  };

  this.MouseDown = function(Event, ItemIndex)
  {
    if (!this._mouseTracker._buttonDown)
    {
      this._mouseTracker.Reset();
      this._mouseTracker._buttonDown = true;

      this.Drag(ItemIndex);
    }
  };

  this.MouseOverLeft = function()
  {
    if (this.GetLeftImage())
      this._leftImage.src = this._leftImageOn.src;
  };

  this.MouseOutLeft = function()
  {
    if (this.GetLeftImage())
      this._leftImage.src = this._leftImageOff.src;
  };

  this.MouseOverRight = function()
  {
    if (this.GetRightImage())
      this._rightImage.src = this._rightImageOn.src;
  };

  this.MouseOutRight = function()
  {
    if (this.GetRightImage())
      this._rightImage.src = this._rightImageOff.src;
  };

  this.CloseImage = function()
  {
    this._galleryItemContainer.Close();
  };
}


Right so we'll start off with the initialisation of the Gallery class - member variables and attached events:

  ///-data-//////////////////////////////////////////////////////////////////
  ///////////////////////////////////////////////////////////////////////////

  this._ID = ID;
  this._layerID = LayerID;
  this._mouseTracker = MouseTracker;

  this._interval = '';

  this._left = 0;
  this._leftTarget = 0;
  this._velocity = 0;
  this._velocityScalar = 0.9;

  this._items = new Array();
  this._itemOrder = new Array();
  this._itemSize = new Vector2D(100, 75);
  this._itemSpacing = 5;
  this._itemsDisplayed = 4;
  this._itemSum = this._itemSize._x + this._itemSpacing;

  this._moveRight = false;

  this._innerLayer = null;

  this._leftImage = null;
  this._leftImageOn = new Image();
  this._leftImageOff = new Image();

  this._rightImage = null;
  this._rightImageOn = new Image();
  this._rightImageOff = new Image();

  this._leftImageOn.src = 'Content/Posts/528/lefton.gif';
  this._leftImageOff.src = 'Content/Posts/528/leftoff.gif';

  this._rightImageOn.src = 'Content/Posts/528/righton.gif';
  this._rightImageOff.src = 'Content/Posts/528/rightoff.gif';

  this._galleryItemContainer = new GalleryItemContainer(
      this._ID + '._galleryItemContainer',
      this._layerID + 'ItemContainer');

  ///-events-////////////////////////////////////////////////////////////////
  ///////////////////////////////////////////////////////////////////////////

  if (window.attachEvent)
  {
    window.document.attachEvent("onselectstart", function(Event) { return false; });
  }


So the member variables of the class then - we have the usual "_ID" and "_layerID" variables which store the IDs of the instance of the Gallery class and the layer that'll contain the gallery markup. We also have an instance of the MouseTracker class stored in "_mouseTracker" that we've used in some of the other examples I've posted. Following this, we have a variable called "_interval" which we'll use to create an animation interval using the JavaScript function setInterval().

Next we have the main guts of the gallery:"_left" is the current left position of the internal layer while "_leftTarget" is the ultimate resting place of the internal layer - this is used to line up the images to the boundaries of the outer layer. "_velocity" is the speed at which the inner layer is moving and "_velocityScalar" is used to decelerate the gallery as it approaches "_leftTarget". Next is the array of the individual gallery items, stored in "_items", the display order of these items based on how far the internal layer has travelled stored in "_itemOrder", the size of an individual item on-screen stored in "_itemSize", the width of the spacing between the items stored in "_itemSpacing" and a value denoting how many items should be displayed on-screen at once stored in "_itemsDisplayed". Finally we have "_itemSum" which adds the width of an item and the spacing between two items together - we store this because we use it frequently during the animation so it's better not to have to repeatedly recalculate it.

We also store a boolean value called "_moveRight" that declares whether the internal layer is being dragged left or right and is used for docking the items after the mouse button has been released, and a variable called "_innerLayer" which is simply a reference to the actual layer that we'll move during the animation - this is resolved using window.document.getElementById and is stored to reduce DOM traversals.

The remaining variables aren't fundamental to the functionality of the gallery and are stored more for convenience than technical necessity - "_leftImage", "_leftImageOn", "_leftImageOff", "_rightImage", "_rightImageOn" and "_rightImageOff" are all used for the left and right scroll buttons and their mouseover / mouseout events, while "_galleryItemContainer" is an instance of a class used for the expanding box that contains the large copy of a clicked gallery item - I'll explain how this works later.

As for the event handling, the code:

  if (window.attachEvent)
  {
    window.document.attachEvent("onselectstart", function(Event) { return false; });
  }


simply prevents IE from highlighting page elements when the mouse button is held down and mouse is dragged - without this we'll get the blue selection highlighting over the gallery items, which doesn't look great to be honest.

Okay, let's look at the gallery methods - the first three:

  this.GetInnerLayer = function()
  {
    if (!this._innerLayer)
      this._innerLayer = wGet(this._layerID + 'Inner');

    return this._innerLayer;
  };

  this.GetLeftImage = function()
  {
    if (!this._leftImage)
      this._leftImage = wGet(this._layerID + 'Left');

    return this._leftImage;
  };

  this.GetRightImage = function()
  {
    if (!this._rightImage)
      this._rightImage = wGet(this._layerID + 'Right');

    return this._rightImage;
  };


are all used to retrieve page elements and store them in their respective local variables, specifically the internal layer that moves during the animation loop and the left and right scroll buttons. Next we have:

  this.GetWidth = function()
  {
    return this._itemSum * this._itemsDisplayed - this._itemSpacing;
  };

  this.GetInnerWidth = function()
  {
    return this.GetWidth() + this._itemSum;
  };


which calculate the widths of the outer and inner layers. The outer width is equal to the number of items we want to display multiplied by the sum of the item width and the item spacing. We then subtract the value of the item spacing from this so the outer layer reaches the boundaries of the first and last images. The internal width is equal to the outer width plus another image and its spacing (for the reasons described above). Following this:

  this.GetItemMarkup = function(Index, Left, ThumbSRC)
  {
    return '<div id="' + this._layerID + Index + '" style="' +
         'position:absolute; ' +
         'left:' + Left + 'px; ' +
         'top:0px; ' +
         'width:' + this._itemSize.GetXPxl() + '; ' +
         'height:' + this._itemSize.GetYPxl() + '; ' +
         'overflow:hidden; ' +
         'background-image:url(' + ThumbSRC + ');" ' +
         'onmousedown="JavaScript:' + this._ID + '.MouseDown(' + Index + ');">\n' +
         '<div id="' + this._layerID + Index + 'Overlay" style="' +
         'position:absolute; ' +
         'left:0px; top:0px; ' +
         'width:100%; height:100%; ' +
         'background-image:url(Content/Posts/528/frame.png);"></div>\n' +
       '</div>\n';
  };


provides the page markup for an individual gallery item. Note how instead of using img elements to display the items, we use the CSS background-image property of a div - this is to stop the browser trying to drag the image instead of the gallery when we click it and move the mouse. We also add another internal layer to each item to place a rounded frame over each image - this is purely stylistic, but I think it looks rather nice. Okay, next is:

  this.AddItem = function(ImageSRC, ThumbnailSRC)
  {
    this._items[this._items.length] = new Object();
    this._items[this._items.length - 1]._image = ImageSRC;
    this._items[this._items.length - 1]._thumb = new Image();
    this._items[this._items.length - 1]._thumb.src = ThumbnailSRC;

    this._itemOrder[this._itemOrder.length] = this._itemOrder.length;
  };


which adds a items to the array. Here we have a nice, simple example of dynamic JavaScript object creation. Each item consists of a large image and a smaller thumbnail. We load each thumbnail into an image object, which will force the browser to actually download the respective image from the server so we can use it in the gallery. The large image however, we'll only load when requested by the user to save bandwidth. We do this by storing the URL of the image, which we'll replace upon request with the necessary image (demonstrated later in DockItems()).

To display items on-screen we use:

  this.DrawItems = function()
  {
    var layer = wGet(this._layerID);
    if (layer)
    {
      layer.style.width = this.GetWidth() + 'px';
      layer.style.height = this._itemSize.GetYPxl();
      layer.style.MozUserSelect = 'none';
      layer.style.KhtmlUserSelect = 'none';

      var markup = '<div id="' + this._layerID + 'Inner" style="' +
          'position:absolute; left:0px; top:0px; ' +
          'width:' + this.GetInnerWidth() + 'px; ' +
          'height:' + this._itemSize.GetYPxl() + ';">\n';

      var left = 0;

      for (var count = 0; count < this._items.length && count < this._itemsDisplayed + 1; ++count)
      {
        markup += this.GetItemMarkup(count, left, this._items[count]._thumb.src);
        left += this._itemSum;
      }

      markup += '</div>\n';

      layer.innerHTML = markup;

      for (var count = 0; count < this._items.length && count < this._itemsDisplayed + 1; ++count)
        IE6PNGBackgroundByID(this._layerID + count + 'Overlay');

      this._galleryItemContainer.FixBackground();
      this._galleryItemContainer.FixCloseButton();
    }
  };


which sets the width and height of the target layer and then sets two additional styles called "MozUserSelect" and "KhtmlUserSelect" to have the value "none". These styles perform the same function as the "onselectstart" event in IE, preventing the highlighting when the user drags the mouse over page elements. These styles apply to Mozilla and Webkit only - to achieve the same aim in Opera is a little trickier and involves setting the focus to a hidden page element upon a mouse movement. Fortunately, for the gallery we don't need to do this - Opera won't exhibit the highlighting behaviour in this context (the explanation of which involves a long and rather laborious discussion on browser quirks which I may embark upon at some point in the distant future).

Next the method creates the markup for the internal layer and loops through the gallery items, collating the markup of each into a string before finally adding it to the outer layer. It then applies a PNG alpha channel fix for IE6 to each item and the container we use to display the large version of the image. I'll explain how this and various other little helper functions that I've written (caret positioning used in the clock for example) work in a subsequent post.

Right now we're getting on to the interesting stuff - let's have a look at how the gallery moves items about:

  this.DockItems = function(ItemIndex)
  {
    if (this._mouseTracker._acceleration.Length() > 0)
    {
      this._leftTarget = Math.floor(this._velocity / 10) * this._itemSum;
    }
    else
    {
      if (Math.round(this._left) != 0 && Math.round(this._left) != -this._itemSum)
      {
        if (this._moveRight)
        {
          this._leftTarget = 0;
          this._velocity = 10;
        }
        else
        {
          this._leftTarget = -this._itemSum;
          this._velocity = -10;
        }
      }
      else if (ItemIndex != null)
      {
        if (!this._items[this._itemOrder[ItemIndex]]._image.src)
        {
          var imageSRC = this._items[this._itemOrder[ItemIndex]]._image;
          this._items[this._itemOrder[ItemIndex]]._image = new Image();
          this._items[this._itemOrder[ItemIndex]]._image.src = imageSRC;
        }

        this._galleryItemContainer.SetContents(
            '<img src="' + this._items[this._itemOrder[ItemIndex]]._image.src + '" ' +
            'width="' + this._galleryItemContainer._openedSize.GetXInt() + '" ' +
            'height="' + this._galleryItemContainer._openedSize.GetYInt() + '" ' +
            'alt="" title="" style="' +
            'position:absolute; left:0px; top:0px; border:none;" />\n');

        this._galleryItemContainer.Open();
      }
    }


    this.Start();
  };


DockItems() does a number of things, but is fundamentally used to ensure that the internal layer always aligns with the edge of the outer layer - if we didn't do this, the animation could stop with images partway in view (and that simply wouldn't do). There are several conditions for which the method checks, the first being whether the items have been "thrown" with the mouse. It does this by checking the length of the mouse acceleration vector - if it is greater than 0, it uses the mouse velocity as a multiplier against the "_itemSum" value to determine an eventual resting place for the internal layer. If there is no acceleration on the mouse, it checks to see if the inner layer is out of place and if so, whether the mouse was moving left or right when the button was released. It then sets the "_leftTarget" variable to the edge of the next appropriate image and sets the velocity of the inner layer to 10.

The method also handles the reaction to an item click (this trigger has filtered down from the MouseDown() method that we'll look at shortly). If the "ItemIndex" argument is valid value and the inner layer is in position, the method will load in the appropriate large image if it doesn't already exist and both set the contents of the large image viewer and open it.

This is followed by Decelerate() which slows the speed of movement of the internal layer during the animation loop:

  this.Decelerate = function()
  {
    var distance = this._leftTarget - this._left;
    if (distance != 0)
    {
      if (distance > 0 && distance < (10 * this._velocity))
        this._velocity *= this._velocityScalar;
      else if (distance < 0 && distance > (10 * this._velocity))
        this._velocity *= this._velocityScalar;
    }

    if (this._velocity < 0 && this._velocity > -2)
      this._velocity = -2;
    else if (this._velocity > 0 && this._velocity < 2)
      this._velocity = 2;
  };


The gallery is slowed based on how far it has left to travel - we work out the remaining distance by subtracting the current position from the target position. If this distance is within a particular range, based on its current direction of travel, we multiply the velocity by our velocity scalar to gradually slow the images.

After this we have the Animate() method, which is pretty straightforward - let's have a look:

  this.Animate = function()
  {
    this.Decelerate();

    this._left += this._velocity;

    this.MoveInnerLayer();

    if (this._left == this._leftTarget && (this._leftTarget == 0 || this._leftTarget == -this._itemSum))
    {
      this.Stop();
    }
    else if (this._velocity == 0)
    {
      this._mouseTracker.Reset();
      this.DockItems();
    }
  };


It simply slows the movement of the gallery with a call to Decelerate(), updates the position of the inner layer and then performs a couple of tests to checks whether to stop the animation completely or to align the items with a call to DockItems(). This method is called on every frame when the gallery is moving independently of the mouse and is joined by the ubiquitous Start() and Stop() methods, which simply control the animation timeout interval:

  this.Start = function()
  {
    if (this._interval == '')
      this._interval = setInterval(this._ID + '.Animate()', 10);
  };

  this.Stop = function()
  {
    if (this._interval != '')
      clearInterval(this._interval);

    this._interval = '';
  };


Following this we have the methods which shift the items within the inner layer:

  this.ShiftItems = function()
  {
    for (var count = 0; count < this._itemsDisplayed + 1; ++count)
    {
      var itemLayer = wGet(this._layerID + count);
      if (itemLayer)
        itemLayer.style.backgroundImage = 'url(' + this._items[this._itemOrder[count]]._thumb.src + ')';
    }
  };

  this.ShiftItemsLeft = function()
  {
    this._itemOrder[this._itemOrder.length] = this._itemOrder[0];
    this._itemOrder.splice(0, 1);

    this.ShiftItems();
  };

  this.ShiftItemsRight = function()
  {
    this._itemOrder.unshift(this._itemOrder[this._itemOrder.length - 1]);
    this._itemOrder.splice(this._itemOrder.length - 1, 1);

    this.ShiftItems();
  };


ShiftItems() simply loops though the item divs within the inner layer and updates the CSS background image of each one based on the order of the items within "_itemOrder". ShiftItemsLeft() and ShiftItemsRight() change the order of the items by moving the index of the first or last item in the array to end or start of the array, before calling ShiftItems() to update the display.

Next is MoveInnerLayer() which actually updates the CSS values of the inner layer, hence moving its position on-screen:

  this.MoveInnerLayer = function()
  {
    if (this.GetInnerLayer())
    {
      if (this._left < -this._itemSum)
      {
        this._left += this._itemSum;
        this._leftTarget += this._itemSum;
        this.ShiftItemsLeft();
      }
      else if (this._left > 0)
      {
        this._left -= this._itemSum;
        this._leftTarget -= this._itemSum;
        this.ShiftItemsRight();
      }

      if (this._velocity < 0 && this._left < this._leftTarget)
        this._left = this._leftTarget;
      else if (this._velocity > 0 && this._left > this._leftTarget)
        this._left = this._leftTarget;

      this._innerLayer.style.left = Math.round(this._left) + 'px';
    }
  };


It starts by attempting to recover a valid reference to the inner layer page element and then checking its current position. If it has gone beyond its allowed range in either direction (greater than zero or less than the sum of the item width and the item spacing), it shifts the internal items and updates the "_left" and "_leftTarget" values accordingly. It then checks that layer hasn't travelled beyond its target position before finally updating the position of the actual page element through its CSS left property.

Then we have the Drag() method which allows the gallery to react to mouse movement:

  this.Drag = function(ItemIndex)
  {
    if (this._mouseTracker._buttonDown)
    {
      if (this._mouseTracker._offset._x < 0 && this._mouseTracker._offset._x < -this._itemSum)
      {
        this._left -= this._itemSum;
      }
      else if (this._mouseTracker._offset._x > 0 && this._mouseTracker._offset._x > this._itemSum)
      {
        this._left += this._itemSum;
      }
      else
      {
        this._left += this._mouseTracker._offset._x;
      }

      this._leftTarget = this._left;

      if (this._mouseTracker._acceleration._x > this._itemSum)
        this._velocity = this._itemSum - 1;
      else if (this._mouseTracker._acceleration._x < -this._itemSum)
        this._velocity = -(this._itemSum - 1);
      else
        this._velocity = this._mouseTracker._acceleration._x;

      if (this._mouseTracker._offset._x != 0)
        this._moveRight = (this._mouseTracker._offset._x > 0);

      this.MoveInnerLayer();

      this._mouseTracker.Reset();

      setTimeout(this._ID + '.Drag(' + ItemIndex + ')', 10);
    }
    else
    {
      this.DockItems(ItemIndex);
    }
  };


First it tests whether the mouse button is depressed and if so, updates the "_left" variable by adding the horizontal movement of the mouse to it. It then stores the velocity of the gallery, based on the acceleration of the mouse (note how it won't allow the gallery to have a velocity larger than the sum of the item width and its spacing - if it did we could end up with empty space during the animation as the internal layer is moved further than the item shifting can compensate for) and determines whether the mouse is moving left or right. Finally, it moves the internal layer before resetting the mouse tracker and calling itself again 10 milliseconds later. If at any point the mouse button is released while dragging the gallery, the next call to Drag() will result in DockItems() being called so as the items can be realigned to the edge of the outer layer.

The next methods handle the behaviour of the scroll left and right buttons:

  this.ScrollLeft = function()
  {
    if (this._interval == '')
    {
      if (this._left == 0)
        this.ShiftItemsRight();

      this._moveRight = true;
      this._left = -(this._itemSum - 1);
      this._mouseTracker.Reset();
      this.DockItems();
    }
  };

  this.ScrollRight = function()
  {
    if (this._interval == '')
    {
      if (this._left == -this._itemSum)
        this.ShiftItemsLeft();

      this._moveRight = false;
      this._left = -1;
      this._mouseTracker.Reset();
      this.DockItems();
    }
  };


They simply check that the gallery isn't already moving (by comparing "_interval" to an empty string) before repositioning the internal layer, setting a couple of internal variables and calling DockItems().

The following MouseDown() method handles the mousedown event on the gallery container:

  this.MouseDown = function(Event, ItemIndex)
  {
    if (!this._mouseTracker._buttonDown)
    {
      this._mouseTracker.Reset();
      this._mouseTracker._buttonDown = true;

      this.Drag(ItemIndex);
    }
  };


As you can see, it checks that the mouse button isn't already depressed and if so, resets the mouse tracker and calls Drag(), passing it the index of the clicked item.

Okay, nearly there now - the methods:

  this.MouseOverLeft = function()
  {
    if (this.GetLeftImage())
      this._leftImage.src = this._leftImageOn.src;
  };

  this.MouseOutLeft = function()
  {
    if (this.GetLeftImage())
      this._leftImage.src = this._leftImageOff.src;
  };

  this.MouseOverRight = function()
  {
    if (this.GetRightImage())
      this._rightImage.src = this._rightImageOn.src;
  };

  this.MouseOutRight = function()
  {
    if (this.GetRightImage())
      this._rightImage.src = this._rightImageOff.src;
  };


simply handle the mouseover and mouseout events for the left and right scroll buttons, while the next method simply calls the Close() method on the item viewer instance:

  this.CloseImage = function()
  {
    this._galleryItemContainer.Close();
  };


Right, that's it for the gallery itself - I've still got to explain how the expanding image viewer box works but this is yet another post that's becoming an absolute behemoth so I'll explain that and the various helper functions I mentioned earlier in another post within the next couple of days. I'll also show you how to make the image viewer box movable and resizable with the mouse - kind of a precursor to my window manager post. In the meantime, here are the code files for the gallery - if you have any questions or there is anything that's not clear, please drop me a line.

Download Support.js
Download Gallery.js

JavaScript Image Gallery
Status: Ready...
Close Window Maximise Window Minimise Window Minimise Window
Contact