In this article, we will learn how to create a directive in Angular that will allow us to freely drag any element, without using any 3rd party libraries.
Let's start coding
1 Create a basic free dragging directive
We will start by creating a basic and simple directive and then will continue to add more features.
1.1 Create a workspace
npm i -g @angular/cli
ng new angular-free-dragging --defaults --minimal
Do not use
--minimal
option in production applications, it creates a workspace without any testing frameworks. You can read more about CLI options.
1.2 Create shared module
ng g m shared
1.3.1 Create free dragging directive
ng g d shared/free-dragging
1.3.2 Export the directive
Once it's created, add it in the exports array of shared module:
// src/app/shared/shared.module.ts
import { NgModule } from "@angular/core";
import { CommonModule } from "@angular/common";
import { FreeDraggingDirective } from "./free-dragging.directive";
@NgModule({
declarations: [FreeDraggingDirective],
imports: [CommonModule],
exports: [FreeDraggingDirective], // Added
})
export class SharedModule {}
1.3.3 Free dragging logic
To have a free dragging, we are going to do below:
- Listen for
mousedown
event on element. This will work as drag-start trigger. - Listen for
mousemove
event on document. This will work as drag trigger. It will also update the position of element based on mouse pointer. - Listen for
mouseup
event on document. This will work as drag-end trigger. With this, we will stop listening tomousemove
event.
For all above listeners, we will create observables. But first, let's setup our directive:
// src/app/shared/free-dragging.directive.ts
@Directive({
selector: "[appFreeDragging]",
})
export class FreeDraggingDirective implements OnInit, OnDestroy {
private element: HTMLElement;
private subscriptions: Subscription[] = [];
constructor(
private elementRef: ElementRef,
@Inject(DOCUMENT) private document: any
) {}
ngOnInit(): void {
this.element = this.elementRef.nativeElement as HTMLElement;
this.initDrag();
}
initDrag(): void {
// main logic will come here
}
ngOnDestroy(): void {
this.subscriptions.forEach((s) => s.unsubscribe());
}
}
In above code, mainly we are doing 3 things:
- Getting native HTML element, so that we can change it's position later on.
- Initiating all dragging operations, we will see this in detail soon.
- At the time of destroying, we are unsubscribing to make resources free.
Let's write dragging functions:
// src/app/shared/free-dragging.directive.ts
...
initDrag(): void {
// 1
const dragStart$ = fromEvent<MouseEvent>(this.element, "mousedown");
const dragEnd$ = fromEvent<MouseEvent>(this.document, "mouseup");
const drag$ = fromEvent<MouseEvent>(this.document, "mousemove").pipe(
takeUntil(dragEnd$)
);
// 2
let initialX: number,
initialY: number,
currentX = 0,
currentY = 0;
let dragSub: Subscription;
// 3
const dragStartSub = dragStart$.subscribe((event: MouseEvent) => {
initialX = event.clientX - currentX;
initialY = event.clientY - currentY;
this.element.classList.add('free-dragging');
// 4
dragSub = drag$.subscribe((event: MouseEvent) => {
event.preventDefault();
currentX = event.clientX - initialX;
currentY = event.clientY - initialY;
this.element.style.transform =
"translate3d(" + currentX + "px, " + currentY + "px, 0)";
});
});
// 5
const dragEndSub = dragEnd$.subscribe(() => {
initialX = currentX;
initialY = currentY;
this.element.classList.remove('free-dragging');
if (dragSub) {
dragSub.unsubscribe();
}
});
// 6
this.subscriptions.push.apply(this.subscriptions, [
dragStartSub,
dragSub,
dragEndSub,
]);
}
...
- We are creating 3 observables for the listeners which we saw earlier using the
[fromEvent](https://rxjs.dev/api/index/function/fromEvent)
function. - Then we are creating some helper variables, which will be needed in updating the position of our element.
- Next we are listening for
mousedown
event on our element. Once user presses mouse, we are storing initial position and we are also adding a classfree-dragging
which will add a nice shadow to element. - We want to move the element only if user has clicked it, that why we are listening for
mousemove
event inside the subscriber ofmousedown
event. When user moves the mouse, we are also updating it's position using transform property. - We are then listening for
mouseup
event. In this we are again updating initial positions so that next drag happens from here. And we are removing thefree-dragging
class. - Lastly, we are pushing all the subscriptions, so that we can unsubscribe from all in
ngOnDestroy
.
It's time to try this out in AppComponent.
1.3.4 Update AppComponent
Replace the content with below:
// src/app/app.component.ts
import { Component } from "@angular/core";
@Component({
selector: "app-root",
// 1 use directive
template: ` <div class="example-box" appFreeDragging>Drag me around</div> `,
// 2 some helper styles
styles: [
`
.example-box {
width: 200px;
height: 200px;
border: solid 1px #ccc;
color: rgba(0, 0, 0, 0.87);
cursor: move;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
background: #fff;
border-radius: 4px;
position: relative;
z-index: 1;
transition: box-shadow 200ms cubic-bezier(0, 0, 0.2, 1);
box-shadow: 0 3px 1px -2px rgba(0, 0, 0, 0.2),
0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 1px 5px 0 rgba(0, 0, 0, 0.12);
}
.example-box.free-dragging {
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2),
0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0, 0, 0, 0.12);
}
`,
],
})
export class AppComponent {}
The above code is simple and clear enough. Let's run it:
ng serve
and see the output:
In current directive, user can drag element by pressing and moving mouse anywhere in the element. Drawback of this is, difficultly in other actions, like selecting the text. And in more practical scenarios, like widgets, you will need an handle for easiness in dragging.
2. Add Support for Drag Handle
We will add support for drag handle by creating one more directive and accessing it with @ContentChild
in our main directive.
2.1 Create a directive for drag handle
ng g d shared/free-dragging-handle
2.2 Export it from shared module
// src/app/shared/shared.module.ts
import { NgModule } from "@angular/core";
import { CommonModule } from "@angular/common";
import { FreeDraggingDirective } from "./free-dragging.directive";
import { FreeDraggingHandleDirective } from './free-dragging-handle.directive';
@NgModule({
declarations: [FreeDraggingDirective, FreeDraggingHandleDirective],
imports: [CommonModule],
exports: [FreeDraggingDirective, FreeDraggingHandleDirective], // Modified
})
export class SharedModule {}
2.3 Return ElementRef from drag handle
We will just need drag handle's element to do the next stuff, let's use ElementRef
for the same:
// src/app/shared/free-dragging-handle.directive.ts
import { Directive, ElementRef } from "@angular/core";
@Directive({
selector: "[appFreeDraggingHandle]",
})
export class FreeDraggingHandleDirective {
constructor(public elementRef: ElementRef<HTMLElement>) {} // Modified
}
2.4 Drag with handle
The logic goes like this:
- Get child drag handle-element from main element
- Listen for
mousedown
event on handle-element. This will work as drag-start trigger. - Listen for
mousemove
event on document. This will work as drag trigger. It will also update the position of main-element (and not only handle-element) based on mouse pointer. - Listen for
mouseup
event on document. This will work as drag-end trigger. With this, we will stop listening tomousemove
event.
So basically, the only change would be to change the element, on which we will listen for mousedown
event.
Let's get back to coding:
// src/app/shared/free-dragging.directive.ts
...
@Directive({
selector: "[appFreeDragging]",
})
export class FreeDraggingDirective implements AfterViewInit, OnDestroy {
private element: HTMLElement;
private subscriptions: Subscription[] = [];
// 1 Added
@ContentChild(FreeDraggingHandleDirective) handle: FreeDraggingHandleDirective;
handleElement: HTMLElement;
constructor(...) {}
// 2 Modified
ngAfterViewInit(): void {
this.element = this.elementRef.nativeElement as HTMLElement;
this.handleElement = this.handle?.elementRef?.nativeElement || this.element;
this.initDrag();
}
initDrag(): void {
// 3 Modified
const dragStart$ = fromEvent<MouseEvent>(this.handleElement, "mousedown");
// rest remains same
}
...
}
We are doing the same as what is explained in logic before the code. Please note that, now instead of ngOnInit
we are using ngAfterViewInit
, because we want to make sure that component's view is fully initialized and we can get the FreeDraggingDirective
if present. You can read more about the same at Angular - Hooking into the component lifecycle.
2.5 Update AppComponent
// src/app/app.component.ts
@Component({
selector: "app-root",
template: `
<!-- 1 use directive -->
<div class="example-box" appFreeDragging>
I can only be dragged using the handle
<!-- 2 use handle directive -->
<div class="example-handle" appFreeDraggingHandle>
<svg width="24px" fill="currentColor" viewBox="0 0 24 24">
<path
d="M10 9h4V6h3l-5-5-5 5h3v3zm-1 1H6V7l-5 5 5 5v-3h3v-4zm14 2l-5-5v3h-3v4h3v3l5-5zm-9 3h-4v3H7l5 5 5-5h-3v-3z"
></path>
<path d="M0 0h24v24H0z" fill="none"></path>
</svg>
</div>
</div>
`,
// 3 helper styles
styles: [
`
.example-box {
width: 200px;
height: 200px;
padding: 10px;
box-sizing: border-box;
border: solid 1px #ccc;
color: rgba(0, 0, 0, 0.87);
display: flex;
justify-content: center;
align-items: center;
text-align: center;
background: #fff;
border-radius: 4px;
position: relative;
z-index: 1;
transition: box-shadow 200ms cubic-bezier(0, 0, 0.2, 1);
box-shadow: 0 3px 1px -2px rgba(0, 0, 0, 0.2),
0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 1px 5px 0 rgba(0, 0, 0, 0.12);
}
.example-box.free-dragging {
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2),
0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0, 0, 0, 0.12);
}
.example-handle {
position: absolute;
top: 10px;
right: 10px;
color: #ccc;
cursor: move;
width: 24px;
height: 24px;
}
`,
],
})
export class AppComponent {}
Let's look at the output:
Great, we have almost achieved what we need.
But, there is still one problem with it. It is allowing user to move element outside the view:
3. Add Support for Dragging Boundary
It's time to add support for boundary. Boundary will help user keep the element inside the desired area.
3.1 Update the directive
For boundary support, we will go like this:
- Add an
@Input
to set custom boundary-element query. By default, we will keep it atbody
. - Check if we can get the boundary-element using
querySelector
, if not throw error. - Use boundary-element's layout height and width to adjust the position of dragged element.
// src/app/shared/free-dragging.directive.ts
...
@Directive({
selector: "[appFreeDragging]",
})
export class FreeDraggingDirective implements AfterViewInit, OnDestroy {
...
// 1 Added
private readonly DEFAULT_DRAGGING_BOUNDARY_QUERY = "body";
@Input() boundaryQuery = this.DEFAULT_DRAGGING_BOUNDARY_QUERY;
draggingBoundaryElement: HTMLElement | HTMLBodyElement;
...
// 2 Modified
ngAfterViewInit(): void {
this.draggingBoundaryElement = (this.document as Document).querySelector(
this.boundaryQuery
);
if (!this.draggingBoundaryElement) {
throw new Error(
"Couldn't find any element with query: " + this.boundaryQuery
);
} else {
this.element = this.elementRef.nativeElement as HTMLElement;
this.handleElement =
this.handle?.elementRef?.nativeElement || this.element;
this.initDrag();
}
}
initDrag(): void {
...
// 3 Min and max boundaries
const minBoundX = this.draggingBoundaryElement.offsetLeft;
const minBoundY = this.draggingBoundaryElement.offsetTop;
const maxBoundX =
minBoundX +
this.draggingBoundaryElement.offsetWidth -
this.element.offsetWidth;
const maxBoundY =
minBoundY +
this.draggingBoundaryElement.offsetHeight -
this.element.offsetHeight;
const dragStartSub = dragStart$.subscribe((event: MouseEvent) => {
...
dragSub = drag$.subscribe((event: MouseEvent) => {
event.preventDefault();
const x = event.clientX - initialX;
const y = event.clientY - initialY;
// 4 Update position relatively
currentX = Math.max(minBoundX, Math.min(x, maxBoundX));
currentY = Math.max(minBoundY, Math.min(y, maxBoundY));
this.element.style.transform =
"translate3d(" + currentX + "px, " + currentY + "px, 0)";
});
});
const dragEndSub = dragEnd$.subscribe(() => {
initialX = currentX;
initialY = currentY;
this.element.classList.remove("free-dragging");
if (dragSub) {
dragSub.unsubscribe();
}
});
this.subscriptions.push.apply(this.subscriptions, [
dragStartSub,
dragSub,
dragEndSub,
]);
}
}
You will also need to set body
's height to 100%, so that you can drag the element around.
// src/styles.css
html,
body {
height: 100%;
}
Let's see the output now:
That's it! Kudos... 🎉😀👍
Conclusion
Let's quickly revise what we did:
✔️ We created a directive for free dragging
✔️ Then added support for drag handle, so that user can perform other actions on element
✔️ Lastly, we also added boundary element, which helps to keep element to be dragged insider a particular boundary
✔️ And all of it without any 3rd party libraries 😉
You can still add many more features to this, I will list a few below:
- Locking axes - allow user to drag only in horizontal or vertical direction
- Events - generate events for each action, like drag-start, dragging and drag-end
- Reset position - move the drag to it's initial position
You can use this dragging feature in many cases, like for a floating widget, chat box, help & support widget, etc. You can also build a fully-featured editor, which supports elements (like headers, buttons, etc.) to be dragged around.
All of above code is available on Github:
shhdharmen / angular-free-dragging
Create a directive in Angular that will allow us to freely drag any element, without using any 3rd party libraries.
Create a directive for free dragging in Angular
In this article, we will learn how to create a directive in Angular that will allow us to freely drag any element, without using any 3rd party libraries.
Reading
This code is created for my article on indepth.dev, you can read it at: Create a directive for free dragging in Angular.
Development
git clone https://github.com/shhdharmen/angular-free-dragging.git
cd angular-free-dragging
npm i
npm start
Thanks for reading this article. Let me know your thoughts and feedback in comments section.
Credits
While writing this article, I took references from code snippets present at w3schools and stackoverflow.
Top comments (0)