Jesper O. Christensen

Cloud Architect @ Brandheroes

Draggable Bootstrap modal with angularJS in TypeScript

2016-05-20 bjørn sørensenangularJS...

unsplash.com

This short post will demonstrate how to make a Bootstrap Modal draggable. I will use the UI Bootstrap library as a base for the modal construction and add a custom directive to make it draggable. I will write the directive in TypeScript, so if you are unfamiliar with this, check it out at the TypeScript website. I will not go in to details on the modal instantiation part here, just the draggable directive.

I’m going to be using the good old Angular Directive. As I want my draggable directive to be available as an attribute, I can not make use of the simpler component construct that was included in AngularJS 1.5, so we stick to a standard directive.

Here is a CodePen showing it in action.

The draggable directive

The directive needs to handle the following flow of events.

  1. A drag is initiated with a mousedown event
  2. The modal is dragged around with mousemove events
  3. The drag is stopped with a mouseup event

Further more, it needs to remember where the modal is relative to its initial position and calculate the new position subtracting the position of the initiating click. I also wan’t to specify a CSS tag on the “handle” element where the drag can be initiated, e.g. <div draggable=".header"></div>. You might have noticed that the modals header is the handle in the CodePen. Let’s get to it.

First I create a basic TypeScript class to hold all the logic. In the constructor I initialize the two positions and get a reference to the wrapping modal frame. I also modify this elements CSS property position to relative to make it moveable. At last I check to see if a handle selector is supplied. If not I use the whole element as a handle. At last I add a move pointer to the handle and attached the mouse down event listener. The interface IPosition holds the x and y properties and IDraggableScope is extending ng.IScope to hold the handle property.

I’ve created a couple of helper methods to set the positions, as I will need to do this several places.

interface IDraggableScope extends ng.IScope {
  draggable?: string;
}
 
interface IPosition {
  x: number;
  y: number;
}

class DraggableDirective {
  private cursorPosition: IPosition;
  private offsetPosition: IPosition;

  private element: JQuery;
  private handle: JQuery;

  constructor(
    $element: ng.IAugmentedJQuery,
    $scope: IDraggableScope,
    private $document: ng.IDocumentService
  ) {
    this.setCursorPosition(0, 0);
    this.setOffsetPosition(0, 0);

    this.element = $element.parent('.modal-content');

    this.element.css({
      position: 'relative'
    });

    if ($scope.draggable) {
      this.handle = angular.element($scope.draggable);
    } else {
      this.handle = this.element;
    }

    this.handle.css({
      cursor: 'move'
    });

    this.handle.on('mousedown', (event: JQueryEventObject) => this.beginDrag(event));
  }

  private setCursorPosition(x: number, y: number): void {
    this.cursorPosition = {
      x: x,
      y: y
    };
  }

  private setOffsetPosition(x: number, y: number): void {
    this.offsetPosition = {
      x: x,
      y: y
    };
  }
}

Next up is the actual drag handling. As shown in line 41, I want to call the method beginDrag when a mouse down event is fired. This method should save the cursor position and setup event listeners for the mousemove and mouseup events on the document. Notice that I save the references to the event listeners on two private fields on the class for later use.

private beginDrag(event: JQueryEventObject): void {
  event.preventDefault();

  const x: number = event.pageX - this.offsetPosition.x;
  const y: number = event.pageY - this.offsetPosition.y;
  this.setCursorPosition(x, y);

  this.mouseMoveEventListener = this.$document.on('mousemove', (e: JQueryEventObject) => this.drag(e));
  this.mouseUpEventListener = this.$document.on('mouseup', (e: JQueryEventObject) => this.endDrag());
}

On the drag method that is fired on every mousemove event. This method should calculate the new position of the modal, save it and move the modal element.

private drag(event: JQueryEventObject): void {
  const x: number = event.pageX - this.cursorPosition.x;
  const y: number = event.pageY - this.cursorPosition.y;

  this.setOffsetPosition(x, y);

  this.element.css({
    left: `${x}px`,
    top: `${y}px`
  });
}

At last, on a mouseup event I need to detach the event listeners. This is done in the endDrag method.

private endDrag(): void {
  this.mouseMoveEventListener.off();
  this.mouseUpEventListener.off();
}

This is it. Well, almost. This class is of no use, if we don’t register it with the directive in Angular. Notice line 8 here where I get the attribute value as a string.

angular
  .module('app')
  .directive('draggable', () => {
    return {
      controller: DraggableDirective,
      restrict: 'A',
      scope: {
        'draggable': '@'
      }
    };
  });

The modal markup

I can now use this directive in my modal markup like this.

<div draggable=".modal-header">
  <div class="modal-header">
    <h3 class="modal-title">I'm a draggable modal!</h3>
  </div>
  <div class="modal-body">
    <p>This is my body</p>
  </div>
</div>

Complete example

Here is the complete TypeScript directive to show you the grand overview. You could all so head over to our GitHub and watch to full source. It’s right here on GitHub.

interface IDraggableScope extends ng.IScope {
  draggable?: string;
}

interface IPosition {
  x: number;
  y: number;
}

class DraggableDirective {
  private cursorPosition: IPosition;
  private offsetPosition: IPosition;

  private mouseMoveEventListener: JQuery;
  private mouseUpEventListener: JQuery;

  private element: JQuery;
  private handle: JQuery;

  constructor(
    $element: ng.IAugmentedJQuery,
    $scope: IDraggableScope,
    private $document: ng.IDocumentService
  ) {
    this.setCursorPosition(0, 0);
    this.setOffsetPosition(0, 0);

    this.element = $element.parent('.modal-content');

    this.element.css({
      position: 'relative'
    });

    if ($scope.draggable) {
      this.handle = angular.element($scope.draggable);
    } else {
      this.handle = this.element;
    }

    this.handle.css({
      cursor: 'move'
    });

    this.handle.on('mousedown', (event: JQueryEventObject) => this.beginDrag(event));
  }

  private beginDrag(event: JQueryEventObject): void {
    event.preventDefault();

    const x: number = event.pageX - this.offsetPosition.x;
    const y: number = event.pageY - this.offsetPosition.y;
    this.setCursorPosition(x, y);

    this.mouseMoveEventListener = this.$document.on('mousemove', (e: JQueryEventObject) => this.drag(e));
    this.mouseUpEventListener = this.$document.on('mouseup', (e: JQueryEventObject) => this.endDrag());
  }

  private drag(event: JQueryEventObject): void {
    const x: number = event.pageX - this.cursorPosition.x;
    const y: number = event.pageY - this.cursorPosition.y;

    this.setOffsetPosition(x, y);

    this.element.css({
      left: `${x}px`,
      top: `${y}px`
    });
  }

  private endDrag(): void {
    this.mouseMoveEventListener.off();
    this.mouseUpEventListener.off();
  }

  private setCursorPosition(x: number, y: number): void {
    this.cursorPosition = {
      x: x,
      y: y
    };
  }

  private setOffsetPosition(x: number, y: number): void {
    this.offsetPosition = {
      x: x,
      y: y
    };
  }
}

angular
  .module('app')
  .directive('draggable', () => {
    return {
      controller: DraggableDirective,
      restrict: 'A',
      scope: {
        'draggable': '@'
      }
    };
  });

Let me know what you think in the comments below or reach out on @Bjorn_Sorensen.