javascriptcssbackground-position

How to make a draggable background image with background-position and percentage values?


I'm trying to make a simple draggable background using background-position and percentage values. I managed to get the drag working so far but I can't seem to find the right calculation for the image to follow the cursor at the same speed (if it makes sense).

Here is a simple example (using only the x axis):

const container = document.querySelector('div');
const containerSize = container.getBoundingClientRect();

let imagePosition = { x: 50, y: 50 };
let cursorPosBefore = { x: 0, y: 0 };
let imagePosBefore = null;
let imagePosAfter = imagePosition;

container.addEventListener('mousedown', function(event) {
  cursorPosBefore = { x: event.clientX, y: event.clientY };
  imagePosBefore = imagePosAfter; // Get current image position
});

container.addEventListener('mousemove', function(event) {
  if (event.buttons === 0) return;

  let newXPos = imagePosBefore.x + ((cursorPosBefore.x - event.clientX) * 100 / containerSize.width);
  newXPos = (newXPos < 0) ? 0 : (newXPos > 100) ? 100 : newXPos; // Stop at the end of the image
  
  imagePosAfter = { x: newXPos, y: imagePosition.y }; // Save position
  container.style.backgroundPosition = `${newXPos}% ${imagePosition.y}%`;
});
div {
  width: 400px;
  height: 400px;
  background-position: 50% 50%;
  background-size: cover;
  background-repeat: no-repeat;
  background-image: url('https://i.sstatic.net/5yqL8.png');
  cursor: move;
  border: 2px solid transparent;
}

div:active {
  border-color: red;
}
<div></div>

If I click on one of the white cross on the background and move the mouse then the cross should always remains under the cursor until I reach either the end of the image or the end of the container.

It's probably just a math problem but I'm a bit confused because of how percentages work with background-position. Any idea?


Solution

  • I dont know how the formula is exactly, but you would have to include the size of the css-background too.

    Your formula would work if the background size is 100% of the container divs size, but it is not. You would need to know the zoom level (which could be calculated from the size in relation to the divs size).

    Here is the formula for when you have your zoom level:

    container.addEventListener('mousemove', function(event) {
        event.preventDefault();
    
        const zoomAffector = (currentZoomLevel - 100) / 100; // 100% zoom = image as big as container
        if (zoomAffector <= 0) return; // Cant drag a image that is zoomed out
    
        let newXPos = imagePosBefore.x + ((imagePosBefore.x - event.pageX) / zoomAffector * 100;);
        newXPos = (newXPos < 0) ? 0 : (newXPos > 100) ? 100 : newXPos;
      
        imagePosAfter = { x: newXPos, y: imagePosition.y };
        container.style.backgroundPosition = `${newXPos}% ${imagePosition.y}%`;
    });
    

    To get the size of the css-background image, maybe checkout this question: Get the Size of a CSS Background Image Using JavaScript?

    Here is my try on your problem using one of the answers from the linked question to get the image width (also did it for y-axis, to be complete):

    const container = document.querySelector('div');
    const containerSize = container.getBoundingClientRect();
    
    let imagePosition = { x: 50, y: 50 };
    let cursorPosBefore = { x: 0, y: 0 };
    let imagePosBefore = null;
    let imagePosAfter = imagePosition;
    
    
    var actualImage = new Image();
    actualImage.src = $('#img').css('background-image').replace(/"/g,"").replace(/url\(|\)$/ig, "");
    actualImage.onload = function() {
        const zoomX = this.width / containerSize.width - 1;
        const zoomY = this.height / containerSize.height - 1;
        
        container.addEventListener('mousedown', function(event) {
          cursorPosBefore = { x: event.clientX, y: event.clientY };
          imagePosBefore = imagePosAfter; // Get current image position
        });
    
        container.addEventListener('mousemove', function(event) {
            event.preventDefault();
    
            if (event.buttons === 0) return;
    
            let newXPos = imagePosBefore.x + ((cursorPosBefore.x - event.clientX) / containerSize.width * 100 / zoomX);
            newXPos = (newXPos < 0) ? 0 : (newXPos > 100) ? 100 : newXPos;
            let newYPos = imagePosBefore.y + ((cursorPosBefore.y - event.clientY) / containerSize.height * 100 / zoomY);
            newYPos = (newYPos < 0) ? 0 : (newYPos > 100) ? 100 : newYPos;
    
            imagePosAfter = { x: newXPos, y: newYPos };
            container.style.backgroundPosition = `${newXPos}% ${newYPos}%`;
        });
    }
    #img {
      width: 400px;
      height: 200px;
      background-position: 50% 50%;
      background-image: url('https://i.sstatic.net/5yqL8.png');
      cursor: move;
      border: 2px solid transparent;
    }
    
    #img:active {
      border-color: red;
    }
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
    <div id="img"></div>

    Or a bit more cleaned up:

    const container = document.querySelector('div');
    const containerSize = container.getBoundingClientRect();
    
    let imagePosition = { x: 50, y: 50 };
    let cursorPosBefore = { x: 0, y: 0 };
    let imagePosBefore = null;
    let imagePosAfter = imagePosition;
    
    // Helpers
    const minMax = (pos) => (pos < 0) ? 0 : (pos > 100) ? 100 : pos;
    const setNewCenter = (x, y) => {
      imagePosAfter = { x: x, y: y }; 
      container.style.backgroundPosition = `${x}% ${y}%`;
    };
    
    const getImageZoom = () => {
      return new Promise((resolve, reject) => {
        let actualImage = new Image();
        actualImage.src = $('#img').css('background-image').replace(/"/g,"").replace(/url\(|\)$/ig, "");
        actualImage.onload = function() {
          resolve({
            x: zoomX = this.width / containerSize.width - 1,
            y: zoomY = this.height / containerSize.height - 1
          });
        }
      });
    }
        
    const addEventListeners = (zoomLevels) => {container.addEventListener('mousedown', function(event) {
          cursorPosBefore = { x: event.clientX, y: event.clientY };
          imagePosBefore = imagePosAfter; // Get current image position
        });
    
        container.addEventListener('mousemove', function(event) {
            event.preventDefault();
    
            if (event.buttons === 0) return;
    
            let newXPos = imagePosBefore.x + ((cursorPosBefore.x - event.clientX) / containerSize.width * 100 / zoomLevels.x);
            let newYPos = imagePosBefore.y + ((cursorPosBefore.y - event.clientY) / containerSize.height * 100 / zoomLevels.y);
    
            setNewCenter(minMax(newXPos), minMax(newYPos));
        });
    };
    
    getImageZoom().then(zoom => addEventListeners(zoom));
        
    #img {
      width: 400px;
      height: 200px;
      background-position: 50% 50%;
      background-image: url('https://i.sstatic.net/5yqL8.png');
      cursor: move;
      border: 2px solid transparent;
    }
    
    #img:active {
      border-color: red;
    }
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
    <div id="img"></div>

    Or to answer your follow-up question:

    const container = document.querySelector("div");
    const containerSize = container.getBoundingClientRect();
    
    let imagePosition = { x: 50, y: 50 };
    let cursorPosBefore = { x: 0, y: 0 };
    let imagePosBefore = null;
    let imagePosAfter = imagePosition;
    
    // Helpers
    const minMax = (pos) => (pos < 0 ? 0 : pos > 100 ? 100 : pos);
    const setNewCenter = (x, y) => {
      imagePosAfter = { x: x, y: y };
      container.style.backgroundPosition = `${x}% ${y}%`;
    };
    
    const getImageZoom = () => {
      return new Promise((resolve, reject) => {
        let actualImage = new Image();
    
        actualImage.src = $("#img")
          .css("background-image")
          .replace(/"/g, "")
          .replace(/url\(|\)$/gi, "");
        actualImage.onload = function () {
          const imgW = this.width,
            imgH = this.height,
            conW = containerSize.width,
            conH = containerSize.height,
            ratioW = imgW / conW,
            ratioH = imgH / conH;
    
          // Stretched to Height
          if (ratioH < ratioW) {
            resolve({
              x: imgW / (conW * ratioH) - 1,
              y: imgH / (conH * ratioH) - 1,
            });
          } else {
            // Stretched to Width
            resolve({
              x: imgW / (conW * ratioW) - 1,
              y: imgH / (conH * ratioW) - 1,
            });
          }
        };
      });
    };
    
    const addEventListeners = (zoomLevels) => {
      container.addEventListener("mousedown", function (event) {
        cursorPosBefore = { x: event.clientX, y: event.clientY };
        imagePosBefore = imagePosAfter; // Get current image position
      });
    
      container.addEventListener("mousemove", function (event) {
        event.preventDefault();
    
        if (event.buttons === 0) return;
    
        let newXPos =
          imagePosBefore.x +
          (((cursorPosBefore.x - event.clientX) / containerSize.width) * 100) /
            zoomLevels.x;
        let newYPos =
          imagePosBefore.y +
          (((cursorPosBefore.y - event.clientY) / containerSize.height) * 100) /
            zoomLevels.y;
    
        setNewCenter(minMax(newXPos), minMax(newYPos));
      });
    };
    
    getImageZoom().then((zoom) => addEventListeners(zoom));
    #img {
      width: 400px;
      height: 200px;
      background-size: cover;
      background-position: 50% 50%;
      background-image: url('https://i.sstatic.net/5yqL8.png');
      cursor: move;
    }
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
    <div id="img"></div>