Commit No Bug
Published on

How I Implemented Drag and Drop Functionality Where I Probably Shouldn't Have

Authors
  • avatar
    Name
    nikUnique
    Twitter
An image with two panels, where on the left we have three columns with card items, while on the right panel we have some JavaScript code

Intro

When I wanted to create a JavaScript project to practice my skills, I decided to build a sea battle game. I started building it, going through obstacles along the way, and time came when I needed to implement manual ship placement. I had no idea how to do that, so I searched and found something about drag-and-drop functionality. I figured out how to implement it in my game. But it wasn't perfect. I implemented it in a way where ships with a size of two cells or more had to be dragged and dropped piece by piece. And not by dragging and dropping the whole ship at once. One of the reasons for that was that I used a <table> HTML element. It was good enough back then, but after some time, I started thinking about making a better ship placement functionality. And it wasn't obvious how to make it so that I could drag and drop the whole ship at once, instead of just parts.

What Else Could I Implement Instead of Drag and Drop?

The functionality that could be implemented is called "snapping" or something similar. It can be implemented using absolute positioning and mouse events like mousemove, mousedown, and mouseup. There, you drag an element whose position snaps/jumps to the nearest grid point, during or at the end of the drag. It is common in editors, puzzle games, tile maps, and diagramming tools. With this technique, a movable element isn't constrained by a place in HTML, as the drag and drop is.

I never implemented it in the game, but I experimented with it in pure JS code until I understood that I was able to implement it. I just didn't want to spend more time replacing the current drag-and-drop with this. The main thing for me was to understand how to implement snapping, and I think I did. Although, after experimenting more with it at the time of writing this article, I realized that it may be a bit harder to do than I thought earlier. And by the way, here is the link to the article, where I talk about the game, and there you also can find a link to the game itself.

By using traditional drag and drop, I couldn't just easily drag and drop big ships on a table element, because it is divided into td elements, which are separate cells in the grid. I guess there is a way to somehow implement whole ship placement with just drag and drop, but back then, I didn't see it or didn't think of it. That's why you can only place ships part by part on a sea battle grid. This is how it looks with just drag and drop:

An image of sea battle grids, where one of them contains ships, while the other has a 'Ready to start' button

Every ship part has to be dragged manually, which takes more effort than placing the whole ship with just one drag and drop. To find more about drag and drop events, you can visit the MDN documentation.

How to Implement Snapping

You can implement snapping in pure JavaScript, but you can also use a library for that. By the way, I myself didn't use any of the libraries for my experiments; I experimented with pure JavaScript code. There is the interact.js library that seems to greatly simplify the process of drag-and-drop and snapping.

AI Implementation of Snapping

By prompting AI here and there, I got a kinda working version. It had one or more bugs in there; I fixed them, and now it is a mini example of how you can implement snapping in pure JavaScript. It seems to be working fine there, but I found out that it will require some time and effort to make it work for multiple draggable pieces on the grid. Here is the code (it's quite long, so I collapsed it — click if interested):

index.html
index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Snapping</title>
    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <div class="container">
      <div class="piece" style="left: 250px; top: 200px"></div>
    </div>

    <script src="script.js" defer></script>

  </body>
</html>
style.css
style.css
:root {
  --grid: 50px;
}

body {
margin: 0;
background: #0a0e23;
min-height: 100vh;
font-family: system-ui, sans-serif;
}

.container {
position: relative;
width: 900px;
height: 600px;
margin: 4rem auto;
background: #0f172a;
border: 6px solid #22d3ee;
border-radius: 1rem;
overflow: hidden;
background-image:
linear-gradient(rgba(34, 211, 238, 0.12) 1px, transparent 1px),
linear-gradient(90deg, rgba(34, 211, 238, 0.12) 1px, transparent 1px);
background-size: var(--grid) var(--grid);
box-shadow: 0 0 3.75rem rgba(0, 0, 0, 0.6);
}

.piece {
position: absolute;
width: calc(var(--grid) _ 1);
height: calc(var(--grid) _ 4);
background: linear-gradient(135deg, #ec4899, #f43f5e);
border-radius: 0.75rem;
color: white;
font-weight: bold;
font-size: 1.2rem;
display: grid;
place-content: center;
text-align: center;
cursor: grab;
user-select: none;
box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.5);
transition: box-shadow 0.2s ease;
}

.piece.dragging {
cursor: grabbing;
box-shadow: 0 1.5rem 3rem rgba(0, 0, 0, 0.7);
z-index: 10;
}

.piece:hover:not(.dragging) {
box-shadow: 0 1rem 2.5rem rgba(236, 72, 153, 0.5);
}

script.js
script.js
const piece = document.querySelector(".piece");
const allPieces = [piece];
const container = document.querySelector(".container");

const GRID_SIZE = 50;

let centerX = 450;
let centerY = 300;
let rotation = 0;
let isDragging = false;
let hasMoved = false;
let isHorizontal = false;

let fixedGrabX = 0;
let fixedGrabY = 0;

function snap(v) {
  return Math.round(v / GRID_SIZE) * GRID_SIZE;
}

function clamp() {
  const cw = container.clientWidth;
  const ch = container.clientHeight;
  const pw = piece.offsetWidth;
  const ph = piece.offsetHeight;

  const isHorizontalTop = isHorizontal && centerY <= 50;
  const isHorizontalBottom = isHorizontal && centerY >= 500;
  const isHorizontalLeft = isHorizontal && centerX <= 100;
  const isVerticalLeft = !isHorizontal && centerX <= 50;
  const isHorizontalRight = isHorizontal && centerX >= 800;
  const isVerticalRight = !isHorizontal && centerX >= 850;

  let placeXOffset = 0;
  let placeYOffset = 0;

  if (isHorizontalTop) {
    placeYOffset = -50;
  }
  if (isHorizontalBottom) {
    placeYOffset = -100;
  }
  if (isHorizontalLeft) {
    placeXOffset = 75;
  }
  if (isHorizontalRight) {
    placeXOffset = 75;
  }
  if (isVerticalLeft) {
    placeXOffset = -25;
  }
  if (isVerticalRight) {
    placeXOffset = 25;
  }

  centerX = Math.max(
    pw / 2 + placeXOffset,
    Math.min(centerX, cw - pw / 2 - placeXOffset),
  );
  centerY = Math.max(
    ph / 2 + placeYOffset,
    Math.min(centerY, ch - ph / 2 - placeYOffset),
  );
}

function update() {
  clamp();

  const placeXOffset = isHorizontal ? 0 : GRID_SIZE / 2;
  const placeYOffset = isHorizontal ? -GRID_SIZE / 2 : 0;
  piece.style.left = `${centerX + placeXOffset}px`;
  piece.style.top = `${centerY + placeYOffset}px`;
  piece.style.transform = `translate(-50%, -50%) rotate(${rotation * 90}deg)`;
}

piece.addEventListener("mousedown", (e) => {
  if (e.button !== 0) return;
  isDragging = true;
  hasMoved = false;
  piece.classList.add("dragging");
  fixedGrabX = e.clientX - centerX;
  fixedGrabY = e.clientY - centerY;
  e.preventDefault();
});

document.addEventListener("mousemove", (e) => {
  if (!isDragging) return;
  centerX = snap(e.clientX - fixedGrabX);
  centerY = snap(e.clientY - fixedGrabY);
  hasMoved = true;
  update();
});

document.addEventListener("mouseup", () => {
  isDragging = false;
  piece.classList.remove("dragging");
});

piece.addEventListener(
  "touchstart",
  (e) => {
    isDragging = true;
    hasMoved = false;
    const t = e.touches[0];
    fixedGrabX = t.clientX - centerX;
    fixedGrabY = t.clientY - centerY;
    e.preventDefault();
  },
  { passive: false },
);

document.addEventListener(
  "touchmove",
  (e) => {
    if (!isDragging) return;
    const t = e.touches[0];
    centerX = snap(t.clientX - fixedGrabX);
    centerY = snap(t.clientY - fixedGrabY);
    hasMoved = true;
    update();
    e.preventDefault();
  },
  { passive: false },
);

piece.addEventListener("click", () => {
  if (hasMoved) {
    hasMoved = false;
    return;
  }
  isHorizontal = !isHorizontal;
  rotation = rotation + 1;
  update();
});

centerX = snap(centerX);
centerY = snap(centerY);
update();

Conclusion

That is the story about how I implemented drag and drop there, where it would have been better to implement snapping using absolute positioning or even an external library like interact.js. If you like it, please share this article with someone who might like it too.

Got questions? Send an email to commitnobug@outlook.com.