Drag and Drop Image that can be Cropable

I have the following js code below which allows the user to drag and drop an image into a container. Within the container, it uses a circle mask to preview only the image content falling inside the circle. CSS for the image uses object-fit: cover to peserve the aspect ratio.

It also allows the user to drag the image to adjust what gets shown in the circular preview container. The code works as expected if the user does not adjust the images after dragging it into the container. The aspect ratio is perserved.

However, if the user shifts the container, for images where aspectRatioNatural > aspectRatioMask, the offsetX is in correctly computed. For aspectRatioNatural > aspectRatioMask, offsetY is incorrectly computed. I’ve tried to adjust the offset using the aspect ratio, but this different solve the issue. The context.drawImage is applying scaling that I can’t seem to figure out.

// Select the drop area and file input elements
let image = document.getElementById('draggableImage');
let isDragging = false;
let startX, startY, initialX, initialY;

let dropArea = document.getElementById('drop-area');
let fileInput = document.getElementById('fileElem');
let uploadForm = document.getElementsByClassName('upload-form')[0];
let deleteBtn = document.getElementsByClassName('delete')[0];
let circleMask = document.getElementById('circle-mask');
let saveImage = document.getElementById('save-image');
let canvas = document.createElement('canvas');

// Prevent default browser behavior for drag and drop
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
  dropArea.addEventListener(eventName, preventDefaults, false);
});

function preventDefaults(e) {
  e.preventDefault();
  e.stopPropagation();
}

// Handle dropped files (uses Drag and Drop API)
dropArea.addEventListener('drop', (e) => {
  let dt = e.dataTransfer;
  let files = dt.files;
  handleFiles(files);
}, false);

// Fallback to file input
document.getElementById('fileSelect').onclick = function() {
  fileInput.click();
};

circleMask.addEventListener('mousedown', function (e) {
  isDragging = true;
  startX = e.clientX;
  startY = e.clientY;
  initialX = image.offsetLeft;
  initialY = image.offsetTop;
  image.style.cursor = 'grabbing';  // Change the cursor to indicate the image is being dragged
});

document.addEventListener('mousemove', function(e) {
  if (isDragging) {
    let dx = e.clientX - startX;
    let dy = e.clientY - startY;

    // Adjust the position of the image
    image.style.left = `${initialX + dx}px`;
    image.style.top = `${initialY + dy}px`;
    image.style.cursor = 'grab';  // Reset the cursor
  }
});

document.addEventListener('mouseup', function() {
  isDragging = false;
});

// Make sure this is an image file (file type) and if valid show inside div/hide form
function handleFiles(files) {
  const file = files[0];
  if (file && file.type.startsWith('image/')) {
    previewFile(file);
    deleteBtn.classList.remove('hidden');
    uploadForm.classList.add('hidden');
  } else {
    alert('Please upload a valid image file.');
  }
}

// Preview file, enable image adjustments via dragging, and show delete button.
function previewFile(file) {
  showPreview();
  let reader = new FileReader();
  reader.readAsDataURL(file);
  reader.onloadend = function () {
    circleMask.innerHTML = ''; // Remove any existing image in the circle mask

    image = document.createElement('img');
    image.src = reader.result;

    // Apply necessary styles to the image
    image.style.position = 'absolute';
    image.style.left = '0px';
    image.style.top = '0px';
    image.style.width = '100%';
    image.style.height = '100%';

    circleMask.appendChild(image);
    document.getElementById('save-image').classList.remove('hidden');
  };
}

function showPreview() {
  dropArea.classList.remove('highlight');
  deleteBtn.classList.remove('hidden');
  circleMask.classList.remove('hidden');
  uploadForm.classList.add('hidden');
}

// Function to save only the masked image in the preview
saveImage.addEventListener('click', function () {
  let canvas = document.createElement('canvas');
  let context = canvas.getContext('2d');

  // Set canvas dimensions to match the mask's size
  const maskWidth = circleMask.offsetWidth;
  const maskHeight = circleMask.offsetHeight;
  canvas.width = maskWidth;
  canvas.height = maskHeight;

  // Get the natural dimensions of the image
  const naturalImageWidth = image.naturalWidth;
  const naturalImageHeight = image.naturalHeight;

  // Get the current image position offsets (adjustments made by dragging)
  const leftOffset = parseInt(image.style.left, 10) || 0;
  const topOffset = parseInt(image.style.top, 10) || 0;

  // Calculate aspect ratios
  const aspectRatioNatural = naturalImageWidth / naturalImageHeight;
  const aspectRatioMask = maskWidth / maskHeight;

  // Variables to store final drawing dimensions
  let drawWidth, drawHeight, offsetX, offsetY;

  // Match the behavior of `object-fit: cover`
  if (aspectRatioNatural > aspectRatioMask) {
    drawHeight = maskHeight;
    drawWidth = naturalImageWidth * (maskHeight / naturalImageHeight);

    // Adjust the X-axis calculation for drag and centering
    offsetX = (maskWidth - drawWidth) / 2 + leftOffset;
    offsetY = topOffset;  // Y-axis is fine
  } else {
    drawWidth = maskWidth;
    drawHeight = naturalImageHeight * (maskWidth / naturalImageWidth);

    offsetX = leftOffset;
    offsetY = topOffset + (maskHeight - drawHeight) / 2;
  }

  // Clear the canvas before drawing
  context.clearRect(0, 0, canvas.width, canvas.height);

  // Clip the context to the circular mask
  context.save();
  context.beginPath();
  const radius = maskWidth / 2;
  context.arc(maskWidth / 2, maskHeight / 2, radius, 0, Math.PI * 2);
  context.clip();

  // Draw the image onto the canvas with object-fit: cover behavior, adjusted for dragging
  context.drawImage(
    image,
    offsetX,  // X position adjusted for drag and object-fit scaling
    offsetY,  // Y position adjusted for drag and object-fit scaling
    drawWidth,  // Width after scaling
    drawHeight  // Height after scaling
  );

  context.restore();  // Restore after clipping

  // Convert the canvas content to a downloadable image
  const croppedImage = canvas.toDataURL('image/png');

  // Trigger the download
  const link = document.createElement('a');
  link.href = croppedImage;
  link.download = 'cropped-image.png';
  link.click();
});

I was expecting the saved image file to be identical to what is shown in the preview file. It does when I don’t move the image once inside the preview container (circle-mask).