ImageZoom component in RactiveJS

March 21, 2016

After reading this article you will be able to write Image Zoom component in Ractive JS.

Step by step instruction

Using a Promise

Our components are using a Promise to correctly display images.

function getAsync(url) {
  return new Promise(function(resolve, reject) {
    var img = new Image();
    img.onload = resolve;
    img.onerror = reject;
    img.setAttribute("src", url);
  })
}

Image Zoom Preview

First component is short and straightforward. It creates variable which contains DOM element where we want to display zoomed image preview.

ImageZoomPreview = Ractive.extend({
  el: 'body',
  append: true,
  template: '#izp-tmpl',
  oninit: function() {
    var self = this;
  },
  onrender: function() {
    var self = this;
    ImageZoomPreview.instance = self;
    self.preview = self.find(".imageZoomPreview");
  }
})

Template

<script id="izp-tmpl" type=text/html>
  <div class="imageZoomPreview {{#onMouseOver}}imageZoomPreview_Border{{/onMouseOver}}" style="width:{{preview_width}}px; height:{{preview_height}}px; ">
      {{#onMouseOver}}
          <img src="{{source_url}}" style="{{previewImageStyle}}">
      {{/onMouseOver}}
  </div>
</script>

Image Zoom

Second component is much larger, and is about 200 lines of code long. Let’s start with oninit function. First, we’ll check if there is already a ImageZoomPreview instance, if not - create one. Then it assigns values that we’ll pass while using component in code.

oninit: function() {
  var self = this;
  if (!ImageZoomPreview.instance) {
    new ImageZoomPreview({
      data: self.get()
    })
  }

  self.set('preview_width', self.get('preview_width') || 360)
  self.set('preview_height', self.get('preview_height') || 360)
  self.set('onMouseOver', false);
  self.set('onZoomActivated', false);

Events

Next thing are events for clicking on thumbnail, getting mouse over it, moving the mouse and moving mouse out of thumbnail.

When you click on thumbnail, script will temporary disable preview, until you mouse out and mouse over thumbnail again.

self.on({
  onThumbMouseClick: function(e) {
    self.set('onMouseOver', false);
    self.set('onZoomActivated', false);
    ImageZoomPreview.instance.data = self.get();
    ImageZoomPreview.instance.update();
  },

When you move your cursor over thumbnail, code below will create a preview based on source image size and component’s position on your website. This code uses getAsync function I have mentioned in the beginning of post.

  onThumbMouseOver: function(e) {
    self.set('onMouseOver', true);
    clearTimeout(self.onMouseOverTimeout);
    self.onMouseOverTimeout = setTimeout(function() {
      if (self.get('onMouseOver') == false) return;

      ImageZoomPreview.instance.data = self.get();
      ImageZoomPreview.instance.update();
      var previewPosition = self.getPreviewPosition();
      ImageZoomPreview.instance.preview.style.left = previewPosition.left + "px";
      ImageZoomPreview.instance.preview.style.top = previewPosition.top + "px";

      self.set('imageLoader', getAsync(self.get('source_url')));
      getAsync(self.get('source_url'))
        .then(function(evt) {
          self.set('onZoomActivated', true);
          try {
            var img;
            if (evt.path && evt.path[0]) {
              img = evt.path[0];
            } else {
              img = evt.target;
            }
            var bPreview = ImageZoomPreview.instance.preview.getBoundingClientRect();
            self.maxZoom = img.width / self.get('preview_width');
            if (self.zoom >= self.maxZoom) self.zoom = self.maxZoom;
          } catch (e) {
            console.log(e);
          } finally {}
        });
    }, 300)
    clearInterval(self.onMouseMoveTimerTimeout)
    self.onMouseMoveTimerTimeout = setInterval(self.onMouseMoveTimer.bind(self), self._mouseMoveTimerInterval)
    self.onMouseMoveTimer();
  },

When you move your mouse outside of thumbnail, this function will be fired. It simply hides DOM element.

  onThumbMouseMove: function(e) {
    this.mouseMoveEvent = e;
  },

  onThumbMouseOut: function(e) {
    self.set('onMouseOver', false)
    self.set('onZoomActivated', false);
    clearInterval(self.onMouseMoveTimerTimeout)
    ImageZoomPreview.instance.data = self.get();
    ImageZoomPreview.instance.update();
    self.set('previewVisibilityClass', 'imageZoom_off');
  }
});

Getting image dimensions

onMouseMoveTimer: function() {
  var self = this;
  var e = self.mouseMoveEvent;
  if (!e) return;

  var percentageX = (e.original.offsetX || e.original.layerX) / (e.original.target.width || e.original.target.naturalWidth);
  var percentageY = (e.original.offsetY || e.original.layerY) / (e.original.target.height || e.original.target.naturalHeight);
  var obj = {
      x: percentageX,
      y: percentageY
    }
  self.setGlassPosition(obj)
  self.setPreviewImagePosition(obj)
},

Setting glass position on thumbnail

This function allows us to set ‘glass’ that will show up when you move your mouse over thumbnail. This glass allows us to choose area that we want to zoom in.

setGlassPosition: function(coords) {
  var self = this;
  var style = ""
  if (self.get('onMouseOver')) {
    var bThumb = self.thumb.getBoundingClientRect();
    var gWid = Math.floor(bThumb.width / self.zoom);
    var gHei = Math.floor(bThumb.height / self.zoom);
    var gWidPer = gWid / bThumb.width;
    var gHeiPer = gHei / bThumb.height;
    var x = 0;
    var y = 0;

    if (coords.x < gWidPer / 2) x = 0;
    else if (coords.x > 1 - gWidPer / 2) x = (1 - gWidPer) * bThumb.width;
    else x = (coords.x - gWidPer / 2) * bThumb.width;

    if (coords.y < gHeiPer / 2) y = 0;
    else if (coords.y > 1 - gHeiPer / 2) y = (1 - gHeiPer) * bThumb.height;
    else y = (coords.y - gHeiPer / 2) * bThumb.height;

    style += "left:" + (x) + "px;";
    style += "top:" + (y) + "px;"
    style += "width:" + gWid + "px;";
    style += "height:" + gHei + "px";
  }
  self.set('glassPosition', style);
},

Setting preview image position

This function will set position of newly created DOM element, inside which will be our previewed image.

setPreviewImagePosition: function(coords) {
  var self = this;
  var style = "";
  if (self.get('onMouseOver')) {
    var bPreview = ImageZoomPreview.instance.preview.getBoundingClientRect();
    var imgWid = self.get('preview_width') * self.zoom;
    var imgHei = self.get('preview_height') * self.zoom;

    var x = -coords.x * (imgWid - self.get('preview_width'));
    var y = -coords.y * (imgHei - self.get('preview_height'));


    style += "left:" + parseInt(x) + "px;";
    style += "top:" + parseInt(y) + "px;"
    style += "width:" + parseInt(imgWid) + "px;";
    style += "height:" + parseInt(imgHei) + "px";
  }
  ImageZoomPreview.instance.set('previewImageStyle', style);
},

Getting preview position

getPreviewPosition: function() {
  var self = this;
  var result = {
    left: 0,
    top: 0
  };

  var w = window,
    d = document,
    e = d.documentElement,
    g = d.getElementsByTagName('body')[0],
    x = w.innerWidth || e.clientWidth || g.clientWidth,
    y = w.innerHeight || e.clientHeight || g.clientHeight;


  if (self.get('onMouseOver')) {
    var bThumb = self.thumb.getBoundingClientRect();
    var bPreview = {
      width: self.get('preview_width'),
      height: self.get('preview_height')
    }
    var margin = 100;
    result.left = bThumb.width + margin;
    if (bThumb.left > bPreview.width + margin) {
      result.left = parseInt(bThumb.left + (-bPreview.width - margin));
      if (bThumb.top > bPreview.height / 2) {
        result.top = parseInt(bThumb.top + bThumb.height / 2 - bPreview.height / 2);
      } else {
        result.top = bThumb.top;
      }
    } else if (x - bThumb.left > bPreview.width + margin) {
      result.left = parseInt(bThumb.left + bThumb.width + margin);
      if (bThumb.top > bPreview.height / 2) {
        result.top = parseInt(bThumb.top + bThumb.height / 2 - bPreview.height / 2);
      } else {
        result.top = bThumb.top;
      }
    }
  }
  return result
}

Other functions

Last thing we need to write is our default values for preview, template, teardown event and render function. These should be easy to understand.

zoom: 4,
maxZoom: 8,
minZoom: 1,
append: true,
template: '#iz-tmpl',
_mouseMoveTimerInterval: 50,

onteardown: function() {
  var self = this;
  clearInterval(self.onMouseMoveTimerTimeout)
},

onrender: function() {
  var self = this;
  self.thumb = self.find('.imageZoom_container>img');
  self.glass = self.find('.imageZoom_glass');
},

Template

Our main component need a template. Inside we’ll place thumbnail image, glass and preloader.

<script id='iz-tmpl' type='text/html'>
  <div class="imageZoom_container">
    <img src="{{thumb_url}}"
      on-mousemove="onThumbMouseMove"
      on-mouseover="onThumbMouseOver"
      on-mouseout="onThumbMouseOut"
      on-click="onThumbMouseClick"
      class="imageZoom_thumb"
      width="{{thumb_width}}" height="{{thumb_height}}">

      {{#imageLoader}}
          {{#pending}}
              <div style="position:absolute; top:0px; left:0px;">
                  {{#if preloaderHTML}}
                      {{{preloaderHTML}}}
                  {{else}}
                      loading
                  {{/if}}
              </div>
          {{/pending}}
          {{#error}}
              <div style="position:absolute; top:0px; left:0px; width:2px; height:120px; background-color: #FF0000">
              </div>
          {{/error}}
      {{/imageLoader}}

      {{#onZoomActivated}}
          <div class="imageZoom_glass" style="{{glassPosition}}"></div>
      {{/onZoomActivated}}
    </div>
</script>

Registering components

To easily use components, let’s register them globally. To do this, use this:

Ractive.components.ImageZoom = ImageZoom;
Ractive.components.ImageZoomPreview = ImageZoomPreview;

Main template

This is the template where we will use our component. To use component type its global name and additional values, like url to image, url to thumbnail and preview dimensions. In our example it will be:

<ImageZoom
  thumb_url="https://dl.dropboxusercontent.com/u/97792319/527006main_farside.1600_thumb.jpg"
  source_url="https://dl.dropboxusercontent.com/u/97792319/527006main_farside.1600.jpg"
  preview_width='360'
  preview_height='360'
/>

So our template should look like this:

<script id='main-tmpl' type='text/html'>
  <div class='main'>
    <ImageZoom
        thumb_url="https://dl.dropboxusercontent.com/u/97792319/527006main_farside.1600_thumb.jpg"
        source_url="https://dl.dropboxusercontent.com/u/97792319/527006main_farside.1600.jpg"
        preview_width='360'
        preview_height='360'
    />
  </div>
</script>

Styles

Styles are available in JSFiddle on the top of the page.

Ractive Initialization

Just basic initialization, our element will be body and template that was mentioned above: #main-tmpl.

var iz = new Ractive({
  el: "body",
  template: '#main-tmpl',
  append: true
})

The entire script and CSS styles are available in JSFiddle on the top of the post. I hope that this post was helpful for you.

Kuba Wyrobek Founder, Meteor Developer
Grzegorz Oleksy Junior Meteor Developer