Snapping into Place: Adding Precision and Boundaries to Your Drag-and-Drop Grid

sandr0p

sandr0-p

Posted on November 11, 2024

Snapping into Place: Adding Precision and Boundaries to Your Drag-and-Drop Grid

Welcome back to the “Drag. Drop. Engage.” series! So far, we’ve built the core of our drag-and-drop functionality and structured it with a visual grid to bring order and flexibility to our dashboard. While these foundations offer a great start, enhancing the user experience requires a touch of precision.

In this article, we’ll take your drag-and-drop interactions to the next level by adding snapping functionality. This ensures that when a user drags an element, its top left corner snaps neatly to the nearest grid cell, creating a clean and intuitive experience. We’ll also introduce movement restrictions that keep the draggable elements within the grid’s boundaries.


For a change, we don’t need a new directive but continue working with the draggable directive we have created in part one of this series. We begin by implementing the getDropZone helper function, which we use to find the parent of the grid. We assume that the parent will contain the ngGrid directive. Let’s also update dragOver() to use this new function.

💡 Remember, depending on your project setup, your directive might have a different prefix.

/**
 * Gets the drop zone of the dragged element
 * @param event DragEvent
 * @returns HTMLElement | null The drop zone element or null if not found
 */
private getDropZone(event: DragEvent): HTMLElement | null {
  let target = event.target as HTMLElement; // Get the target of the event
  while (target) { // Loop through the target and its parents
    if (target.hasAttribute('ngGrid')) { // Check if the target has the 'ngGrid' attribute
      return target; // Return true if it does
    }
    target = target.parentElement as HTMLElement; // Move to the parent of the target
  }
  return null; // Return false if the 'ngGrid' attribute is not found
}

/**
 * DragOver event handler
 * Prevents the default behaviour of the event to allow dropping the element anywhere
 * @param event DragEvent
 */
private dragOver(event: DragEvent): void {
  if (this.getDropZone(event)) {
    event.preventDefault();
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, we will calculate the snapping position of the element and ensure that it stays within the grid's boundaries. We also add an @Input parameter to set the cell width.

@Input('cellSize') cellSize: number = 50;

/**
 * Calculates the position of the top left corner for the dragged element within the drop zone
 * and ensures the element does not go outside the drop zone
 * @param element DOMRect of the dragged element
 * @param zone DOMRect of the drop zone
 * @returns { x: number, y: number } The new position of the element
 */
private calculatePosition(event: DragEvent, element: DOMRect): { x: number, y: number } {

  let dropZone = this.getDropZone(event)?.getBoundingClientRect(); // Get the rect of the drop zone
  if (!dropZone) return { x: 0, y: 0 }; // Return 0, 0 if the drop zone is not found

  let x = event.clientX + this._offset.x; // Prepare x by adding the mouse
  let y = event.clientY + this._offset.y; // Prepare y by adding the mouse


  x = Math.round(x / this.cellSize) * this.cellSize; // Calculate the new x position
  y = Math.round(y / this.cellSize) * this.cellSize; // Calculate the new y position

  if (x < 0) x = 0; // if element is too far left, set it to the left edge
  if (y < 0) y = 0; // if element is too far up, set it to the top edge
  if (x + element.width > dropZone.width) { // if element is too far right, set it to the right edge
    let delta = x + element.width - dropZone.width; // Calculate how far the element is past the right edge
    x = Math.floor((x - delta) / this.cellSize) * this.cellSize; // recalulate the x position
  }
  if (y + element.height > dropZone.height) { // if element is too far down, set it to the bottom edge
    let delta = y + element.height - dropZone.height; // Calculate how far the element is past the bottom edge
    y = Math.floor((y - delta) / this.cellSize) * this.cellSize; // recalulate the y position
  }

  return { x, y }; // Return the new
}
Enter fullscreen mode Exit fullscreen mode

With the calculation in place, we only have to add the drag event listener to our element. In the drag event handler, we calculate the position of the object we are dragging. As the event is fired every few milliseconds, we ensure we recalculate the position only when the mouse is moving.

private _lastPosition: { x: number, y: number } = { x: 0, y: 0 };

/**
 * Constructor
 * Initializes styles, attributes, and event listeners for the element
 * @param element Injected reference to the element this directive is attached to
 */
constructor(private element: ElementRef) {
  ...
  this.element.nativeElement.addEventListener('drag', 
  this.drag.bind(this)); // Add event listener for drag
  ...
}

/**
 * Drag event handler
 * Updates the position of the element to the mouse pointer's position
 * @param event DragEvent
 */
private drag(event: DragEvent): void {
  let currentPosition = { x: event.clientX, y: event.clientY }; // Get the current position of the mouse pointer
  if (currentPosition.x === this._lastPosition.x && currentPosition.y === this._lastPosition.y) return;// check if the position has changed
  this._lastPosition = currentPosition; // Update the last position to the current position

  var position = this.calculatePosition(event, this._element.nativeElement?.getBoundingClientRect()); // Calculate the new position
  this._element.nativeElement.style.left = position.x + 'px'; // Set the new x position
  this._element.nativeElement.style.top = position.y + 'px'; // Set the new y position
}
Enter fullscreen mode Exit fullscreen mode

We also remove the ghost image the browser generates automatically. To do this, we create a new Image and set a transparent pixel as its src, and call the removeGhost from dragStart()

💡 Unfortunately, there seems to be a little bug in Chromium browsers, which generates a little icon on the first drag but doesn’t on subsequent drags.

/**
 * Removes the ghost image of the dragged element
 * @param event DragEvent
 */
private removeGhost(event: DragEvent): void {
  let image = new Image(); // Create a new image
  image.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=='; // Set the source of the image
  event.dataTransfer?.setDragImage(image, 0, 0); // Set the drag image to the new image
}

/**
 * DragStart event handler
 * Calculates the offset of the mouse pointer from the top-left corner of the element for correct dropping
 * @param event DragEvent
 */
private dragStart(event: DragEvent): void {
  this.removeGhost(event); // Remove the ghost image of the dragged element
  ...
}
Enter fullscreen mode Exit fullscreen mode

Lastly, we update the ngAfterViewInit method of the ngGrid component to ensure the positioning of our draggables is correct.

ngAfterViewInit(): void {
  this._container.nativeElement.style.position = 'relative';
  ...
}
Enter fullscreen mode Exit fullscreen mode

And as always, here is a functional demo of the project's current state.


With the snapping and restrictions in place, we are almost done. In this series's next (and probably last) article, we will add functionality to reposition existing cards if they overlap to keep the dashboard tidy.

Feel free to fork this example and share your variations or enhancements if you'd like.

💖 💪 🙅 🚩
sandr0p
sandr0-p

Posted on November 11, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related