Skip to content

Instantly share code, notes, and snippets.

@redgeoff
Last active March 30, 2022 06:15
Show Gist options
  • Save redgeoff/6be0295e6ebf18649966d48768398252 to your computer and use it in GitHub Desktop.
Save redgeoff/6be0295e6ebf18649966d48768398252 to your computer and use it in GitHub Desktop.
Pinch Zoom And Pan With HammerJS
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport"
content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1">
<title>Pinch Zoom</title>
</head>
<body>
<div>
<div style="height:150px;background-color:#eeeeee">
Ignore this area. Space is needed to test on the iPhone simulator as pinch simulation on the
iPhone simulator requires the target to be near the middle of the screen and we only respect
touch events in the image area. This space is not needed in production.
</div>
<style>
.pinch-zoom-container {
overflow: hidden;
height: 300px;
}
.pinch-zoom-image {
width: 100%;
}
</style>
<script src="https://hammerjs.github.io/dist/hammer.js"></script>
<script>
var MIN_SCALE = 1; // 1=scaling when first loaded
var MAX_SCALE = 64;
// HammerJS fires "pinch" and "pan" events that are cumulative in nature and not
// deltas. Therefore, we need to store the "last" values of scale, x and y so that we can
// adjust the UI accordingly. It isn't until the "pinchend" and "panend" events are received
// that we can set the "last" values.
// Our "raw" coordinates are not scaled. This allows us to only have to modify our stored
// coordinates when the UI is updated. It also simplifies our calculations as these
// coordinates are without respect to the current scale.
var imgWidth = null;
var imgHeight = null;
var viewportWidth = null;
var viewportHeight = null;
var scale = null;
var lastScale = null;
var container = null;
var img = null;
var x = 0;
var lastX = 0;
var y = 0;
var lastY = 0;
var pinchCenter = null;
// We need to disable the following event handlers so that the browser doesn't try to
// automatically handle our image drag gestures.
var disableImgEventHandlers = function () {
var events = ['onclick', 'onmousedown', 'onmousemove', 'onmouseout', 'onmouseover',
'onmouseup', 'ondblclick', 'onfocus', 'onblur'];
events.forEach(function (event) {
img[event] = function () {
return false;
};
});
};
// Traverse the DOM to calculate the absolute position of an element
var absolutePosition = function (el) {
var x = 0,
y = 0;
while (el !== null) {
x += el.offsetLeft;
y += el.offsetTop;
el = el.offsetParent;
}
return { x: x, y: y };
};
var restrictScale = function (scale) {
if (scale < MIN_SCALE) {
scale = MIN_SCALE;
} else if (scale > MAX_SCALE) {
scale = MAX_SCALE;
}
return scale;
};
var restrictRawPos = function (pos, viewportDim, imgDim) {
if (pos < viewportDim/scale - imgDim) { // too far left/up?
pos = viewportDim/scale - imgDim;
} else if (pos > 0) { // too far right/down?
pos = 0;
}
return pos;
};
var updateLastPos = function (deltaX, deltaY) {
lastX = x;
lastY = y;
};
var translate = function (deltaX, deltaY) {
// We restrict to the min of the viewport width/height or current width/height as the
// current width/height may be smaller than the viewport width/height
var newX = restrictRawPos(lastX + deltaX/scale,
Math.min(viewportWidth, curWidth), imgWidth);
x = newX;
img.style.marginLeft = Math.ceil(newX*scale) + 'px';
var newY = restrictRawPos(lastY + deltaY/scale,
Math.min(viewportHeight, curHeight), imgHeight);
y = newY;
img.style.marginTop = Math.ceil(newY*scale) + 'px';
};
var zoom = function (scaleBy) {
scale = restrictScale(lastScale*scaleBy);
curWidth = imgWidth*scale;
curHeight = imgHeight*scale;
img.style.width = Math.ceil(curWidth) + 'px';
img.style.height = Math.ceil(curHeight) + 'px';
// Adjust margins to make sure that we aren't out of bounds
translate(0, 0);
};
var rawCenter = function (e) {
var pos = absolutePosition(container);
// We need to account for the scroll position
var scrollLeft = window.pageXOffset ? window.pageXOffset : document.body.scrollLeft;
var scrollTop = window.pageYOffset ? window.pageYOffset : document.body.scrollTop;
var zoomX = -x + (e.center.x - pos.x + scrollLeft)/scale;
var zoomY = -y + (e.center.y - pos.y + scrollTop)/scale;
return { x: zoomX, y: zoomY };
};
var updateLastScale = function () {
lastScale = scale;
};
var zoomAround = function (scaleBy, rawZoomX, rawZoomY, doNotUpdateLast) {
// Zoom
zoom(scaleBy);
// New raw center of viewport
var rawCenterX = -x + Math.min(viewportWidth, curWidth)/2/scale;
var rawCenterY = -y + Math.min(viewportHeight, curHeight)/2/scale;
// Delta
var deltaX = (rawCenterX - rawZoomX)*scale;
var deltaY = (rawCenterY - rawZoomY)*scale;
// Translate back to zoom center
translate(deltaX, deltaY);
if (!doNotUpdateLast) {
updateLastScale();
updateLastPos();
}
};
var zoomCenter = function (scaleBy) {
// Center of viewport
var zoomX = -x + Math.min(viewportWidth, curWidth)/2/scale;
var zoomY = -y + Math.min(viewportHeight, curHeight)/2/scale;
zoomAround(scaleBy, zoomX, zoomY);
};
var zoomIn = function () {
zoomCenter(2);
};
var zoomOut = function () {
zoomCenter(1/2);
};
var onLoad = function () {
img = document.getElementById('pinch-zoom-image-id');
container = img.parentElement;
disableImgEventHandlers();
imgWidth = img.width;
imgHeight = img.height;
viewportWidth = img.offsetWidth;
scale = viewportWidth/imgWidth;
lastScale = scale;
viewportHeight = img.parentElement.offsetHeight;
curWidth = imgWidth*scale;
curHeight = imgHeight*scale;
var hammer = new Hammer(container, {
domEvents: true
});
hammer.get('pinch').set({
enable: true
});
hammer.on('pan', function (e) {
translate(e.deltaX, e.deltaY);
});
hammer.on('panend', function (e) {
updateLastPos();
});
hammer.on('pinch', function (e) {
// We only calculate the pinch center on the first pinch event as we want the center to
// stay consistent during the entire pinch
if (pinchCenter === null) {
pinchCenter = rawCenter(e);
var offsetX = pinchCenter.x*scale - (-x*scale + Math.min(viewportWidth, curWidth)/2);
var offsetY = pinchCenter.y*scale - (-y*scale + Math.min(viewportHeight, curHeight)/2);
pinchCenterOffset = { x: offsetX, y: offsetY };
}
// When the user pinch zooms, she/he expects the pinch center to remain in the same
// relative location of the screen. To achieve this, the raw zoom center is calculated by
// first storing the pinch center and the scaled offset to the current center of the
// image. The new scale is then used to calculate the zoom center. This has the effect of
// actually translating the zoom center on each pinch zoom event.
var newScale = restrictScale(scale*e.scale);
var zoomX = pinchCenter.x*newScale - pinchCenterOffset.x;
var zoomY = pinchCenter.y*newScale - pinchCenterOffset.y;
var zoomCenter = { x: zoomX/newScale, y: zoomY/newScale };
zoomAround(e.scale, zoomCenter.x, zoomCenter.y, true);
});
hammer.on('pinchend', function (e) {
updateLastScale();
updateLastPos();
pinchCenter = null;
});
hammer.on('doubletap', function (e) {
var c = rawCenter(e);
zoomAround(2, c.x, c.y);
});
};
</script>
<button onclick="zoomIn()">Zoom In</button>
<button onclick="zoomOut()">Zoom Out</button>
<div class="pinch-zoom-container">
<img id="pinch-zoom-image-id" class="pinch-zoom-image" onload="onLoad()"
src="https://hammerjs.github.io/assets/img/pano-1.jpg">
</div>
</div>
</body>
</html>
@mendeleev
Copy link

Hi @redgeoff,
What licence does your code have? Is that free to use?

@TheRealSlapshot
Copy link

Hi @redgeoff,

I would like to use your code as a base for implementing this feature in React.
Can you add any license-information or just give direct permission to use it and in which ways.

Kind regards
Benjamin

@juanpablob
Copy link

Hello @redgeoff, first of all, thank you so much for this. I have put your code inside a function that returns the hammer instance so I can instance the pinch zoom feature through several objects and destroy them whenever I want. The only issue I am facing is that, for instance I have a lot of boxes that contains a map inside, so every time you open one of this boxes, the map is shown therefore your script is instanced for that object. Everything works fine for the first box you open, same with the second one, but once you open the second box and your script got instanced for the second box, if you try to pinch over the first box (first instance of the script), it will be pinching over the second box/instance, which is quite rare!

I do not know what might be happening. I debugged the hammer instance for each box and they have the right DOM element attached, so honestly I have no clue about what might be happening. Do you have any ideas? I appreciate any hint you might have. Attached is a screenshot so you can have an idea what is it about. Also attached is the code I have, which is exactly the same as your script but I wrapped it into a function that returns the hammer instance.

Thank you in advance!

Cheers!

Screenshoot

The code:

var hammerPanZoom = (function() {
  var MIN_SCALE = 1; // 1=scaling when first loaded
  var MAX_SCALE = 4;

  // HammerJS fires "pinch" and "pan" events that are cumulative in nature and not
  // deltas. Therefore, we need to store the "last" values of scale, x and y so that we can
  // adjust the UI accordingly. It isn't until the "pinchend" and "panend" events are received
  // that we can set the "last" values.

  // Our "raw" coordinates are not scaled. This allows us to only have to modify our stored
  // coordinates when the UI is updated. It also simplifies our calculations as these
  // coordinates are without respect to the current scale.

  var imgWidth = null;
  var imgHeight = null;
  var viewportWidth = null;
  var viewportHeight = null;
  var scale = null;
  var lastScale = null;
  var container = null;
  var img = null;
  var x = 0;
  var lastX = 0;
  var y = 0;
  var lastY = 0;
  var pinchCenter = null;

  // We need to disable the following event handlers so that the browser doesn't try to
  // automatically handle our image drag gestures.
  var disableImgEventHandlers = function () {
    var events = ['onclick', 'onmousedown', 'onmousemove', 'onmouseout', 'onmouseover',
                  'onmouseup', 'ondblclick', 'onfocus', 'onblur'];

    events.forEach(function (event) {
      img[event] = function () {
        return false;
      };
    });
  };

  // Traverse the DOM to calculate the absolute position of an element
  var absolutePosition = function (el) {
    var x = 0,
      y = 0;

    while (el !== null) {
      x += el.offsetLeft;
      y += el.offsetTop;
      el = el.offsetParent;
    }

    return { x: x, y: y };
  };

  var restrictScale = function (scale) {
    if (scale < MIN_SCALE) {
      scale = MIN_SCALE;
    } else if (scale > MAX_SCALE) {
      scale = MAX_SCALE;
    }
    return scale;
  };

  var restrictRawPos = function (pos, viewportDim, imgDim) {
    if (pos < viewportDim/scale - imgDim) { // too far left/up?
      pos = viewportDim/scale - imgDim;
    } else if (pos > 0) { // too far right/down?
      pos = 0;
    }
    return pos;
  };

  var updateLastPos = function (deltaX, deltaY) {
    lastX = x;
    lastY = y;
  };

  var translate = function (deltaX, deltaY) {
    // We restrict to the min of the viewport width/height or current width/height as the
    // current width/height may be smaller than the viewport width/height

    var newX = restrictRawPos(lastX + deltaX/scale,
                              Math.min(viewportWidth, curWidth), imgWidth);
    x = newX;
    img.style.marginLeft = Math.ceil(newX*scale) + 'px';

    var newY = restrictRawPos(lastY + deltaY/scale,
                              Math.min(viewportHeight, curHeight), imgHeight);
    y = newY;
    img.style.marginTop = Math.ceil(newY*scale) + 'px';
  };

  var zoom = function (scaleBy) {
    scale = restrictScale(lastScale*scaleBy);

    curWidth = imgWidth*scale;
    curHeight = imgHeight*scale;

    img.style.width = Math.ceil(curWidth) + 'px';
    img.style.height = Math.ceil(curHeight) + 'px';

    // Adjust margins to make sure that we aren't out of bounds
    translate(0, 0);
  };

  var rawCenter = function (e) {
    var pos = absolutePosition(container);

    // We need to account for the scroll position
    var scrollLeft = window.pageXOffset ? window.pageXOffset : document.body.scrollLeft;
    var scrollTop = window.pageYOffset ? window.pageYOffset : document.body.scrollTop;

    var zoomX = -x + (e.center.x - pos.x + scrollLeft)/scale;
    var zoomY = -y + (e.center.y - pos.y + scrollTop)/scale;

    return { x: zoomX, y: zoomY };
  };

  var updateLastScale = function () {
    lastScale = scale;
  };

  var zoomAround = function (scaleBy, rawZoomX, rawZoomY, doNotUpdateLast) {
    // Zoom
    zoom(scaleBy);

    // New raw center of viewport
    var rawCenterX = -x + Math.min(viewportWidth, curWidth)/2/scale;
    var rawCenterY = -y + Math.min(viewportHeight, curHeight)/2/scale;

    // Delta
    var deltaX = (rawCenterX - rawZoomX)*scale;
    var deltaY = (rawCenterY - rawZoomY)*scale;

    // Translate back to zoom center
    translate(deltaX, deltaY);

    if (!doNotUpdateLast) {
      updateLastScale();
      updateLastPos();
    }
  };

  var zoomCenter = function (scaleBy) {
    // Center of viewport
    var zoomX = -x + Math.min(viewportWidth, curWidth)/2/scale;
    var zoomY = -y + Math.min(viewportHeight, curHeight)/2/scale;

    zoomAround(scaleBy, zoomX, zoomY);
  };

  var zoomIn = function () {
    zoomCenter(2);
  };

  var zoomOut = function () {
    zoomCenter(1/2);
  };

  var init = function ($element) {
    img = $element[0];
    container = img.parentElement;

    disableImgEventHandlers();

    imgWidth = img.width;
    imgHeight = img.height;
    viewportWidth = img.offsetWidth;
    scale = viewportWidth/imgWidth;
    lastScale = scale;
    viewportHeight = img.parentElement.offsetHeight;
    curWidth = imgWidth*scale;
    curHeight = imgHeight*scale;

    var hammer = new Hammer(container, {
      domEvents: true
    });
    console.log('hammer container: ----');
    console.log(container);

    hammer.get('pinch').set({
      enable: true
    });

    hammer.on('pan', function (e) {
      translate(e.deltaX, e.deltaY);
    });

    hammer.on('panend', function (e) {
      updateLastPos();
    });

    hammer.on('pinch', function (e) {

      // We only calculate the pinch center on the first pinch event as we want the center to
      // stay consistent during the entire pinch
      if (pinchCenter === null) {
        pinchCenter = rawCenter(e);
        var offsetX = pinchCenter.x*scale - (-x*scale + Math.min(viewportWidth, curWidth)/2);
        var offsetY = pinchCenter.y*scale - (-y*scale + Math.min(viewportHeight, curHeight)/2);
        pinchCenterOffset = { x: offsetX, y: offsetY };
      }

      // When the user pinch zooms, she/he expects the pinch center to remain in the same
      // relative location of the screen. To achieve this, the raw zoom center is calculated by
      // first storing the pinch center and the scaled offset to the current center of the
      // image. The new scale is then used to calculate the zoom center. This has the effect of
      // actually translating the zoom center on each pinch zoom event.
      var newScale = restrictScale(scale*e.scale);
      var zoomX = pinchCenter.x*newScale - pinchCenterOffset.x;
      var zoomY = pinchCenter.y*newScale - pinchCenterOffset.y;
      var zoomCenter = { x: zoomX/newScale, y: zoomY/newScale };

      zoomAround(e.scale, zoomCenter.x, zoomCenter.y, true);
    });

    hammer.on('pinchend', function (e) {
      updateLastScale();
      updateLastPos();
      pinchCenter = null;
    });

    hammer.on('doubletap', function (e) {
      var c = rawCenter(e);
      zoomAround(2, c.x, c.y);
    });

    return hammer;
  };

  return {
    init: init
  };
})();

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment