Create a directive for free dragging in Angular

Create a directive for free dragging in Angular

ยท

16 min read

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:

  1. Listen for mousedown event on element. This will work as drag-start trigger.
  2. Listen for mousemove event on document. This will work as drag trigger. It will also update the position of element based on mouse pointer.
  3. Listen for mouseup event on document. This will work as drag-end trigger. With this, we will stop listening to mousemove 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:

  1. Getting native HTML element, so that we can change it's position later on.
  2. Initiating all dragging operations, we will see this in detail soon.
  3. 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,
    ]);
  }

...
  1. We are creating 3 observables for the listeners which we saw earlier using the [fromEvent](https://rxjs.dev/api/index/function/fromEvent) function.
  2. Then we are creating some helper variables, which will be needed in updating the position of our element.
  3. 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 class free-dragging which will add a nice shadow to element.
  4. We want to move the element only if user has clicked it, that why we are listening for mousemove event inside the subscriber of mousedown event. When user moves the mouse, we are also updating it's position using transform property.
  5. 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 the free-dragging class.
  6. 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:

Output after step 4

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:

  1. Get child drag handle-element from main element
  2. Listen for mousedown event on handle-element. This will work as drag-start trigger.
  3. 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.
  4. Listen for mouseup event on document. This will work as drag-end trigger. With this, we will stop listening to mousemove 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:

output after step 7

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:

allowing to drag beyond 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:

  1. Add an @Input to set custom boundary-element query. By default, we will keep it at body.
  2. Check if we can get the boundary-element using querySelector, if not throw error.
  3. 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:

  1. Locking axes - allow user to drag only in horizontal or vertical direction
  2. Events - generate events for each action, like drag-start, dragging and drag-end
  3. 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:

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.


This article was originally published at indepth.dev

Did you find this article valuable?

Support Dharmen Shah by becoming a sponsor. Any amount is appreciated!