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();
}
}
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
}
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
}
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
...
}
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';
...
}
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.
Top comments (0)