How to implement a draggable div in Angular 2 using Rx
Asked Answered
C

5

22

I've been trying to get a draggable div working using Angular 2. I'm using this example from the angular2-examples repo as a starting point, only really adjusting the code to account for the removal of the toRx() method. The code works, but it does not account for mouseout events. This means that if I click on a Draggable div, and move the mouse slowly, the div will move with the mouse. But if I move the mouse too fast, a mouseout event is sent instead of a mousemove event, and the dragging stops.

How can I keep the drag going after the mouse is moved so far that a mouseout event is fired? I've tried merging the mouseout event stream with the mousemove one, so that mouseout events are treated just like mousemove ones, but that doesn't work.

I'm using Angular 2.0.0-beta.12.

import {Component, Directive, HostListener, EventEmitter, ElementRef, OnInit} from 'angular2/core';
import {map, merge} from 'rxjs/Rx';

@Directive({
    selector: '[draggable]'
})
export class Draggable implements OnInit {

    mouseup = new EventEmitter();
    mousedown = new EventEmitter();
    mousemove = new EventEmitter();
    mouseout = new EventEmitter();

    @HostListener('mouseup', ['$event'])
    onMouseup(event) {
        this.mouseup.emit(event);
    }

    @HostListener('mousedown', ['$event'])
    onMousedown(event) {
        this.mousedown.emit(event);
        return false; // Call preventDefault() on the event
    }

    @HostListener('mousemove', ['$event'])
    onMousemove(event) {
        this.mousemove.emit(event);
    }

    @HostListener('mouseout', ['$event'])
    onMouseout(event) {
        this.mouseout.emit(event);
        return false; // Call preventDefault() on the event
    }

    constructor(public element: ElementRef) {
        this.element.nativeElement.style.position = 'relative';
        this.element.nativeElement.style.cursor = 'pointer';

        map;
        merge;
        this.mousedrag = this.mousedown.map(event => {
            return {
                top: event.clientY - this.element.nativeElement.getBoundingClientRect().top
                left: event.clientX - this.element.nativeElement.getBoundingClientRect().left,
            };
        })
        .flatMap(
            imageOffset => this.mousemove.merge(this.mouseout).map(pos => ({
                top: pos.clientY - imageOffset.top,
                left: pos.clientX - imageOffset.left
            }))
            .takeUntil(this.mouseup)
        );
    }

    ngOnInit() {
        this.mousedrag.subscribe({
            next: pos => {
                this.element.nativeElement.style.top = pos.top + 'px';
                this.element.nativeElement.style.left = pos.left + 'px';
            }
        });
    }
}

@Component({
    selector: 'my-app',
    template: `
        <div draggable>
            <h1>Hello, World!</h1>
        </div>
        `,
    directives: [Draggable,],
})
export class AppComponent {
}
Cohby answered 29/3, 2016 at 1:14 Comment(1)
Possible duplicate of RxJs How do deal with document eventsCohby
C
30

I found the answer to this in RxJs How do deal with document events. The crux of the problem is that mouse events are only sent to an element when the mouse is over that element. So we do want the mousedown event limited to specific element, but we have to track global mousemove and mouseup events. Here's the new code. Notice the use of the @HostListener decorator on onMouseup and onMousemove specifies the target as document:mouseup and document:mousemove. This is how the global events are piped into the Rx stream.

The official angular2 documentation for HostListener doesn't mention this target:eventName syntax, but this old dart documentation for 2.0.0-alpha.24 does mention it. It seems to still work in 2.0.0-beta.12.

@Directive({
    selector: '[draggable]'
})
export class Draggable implements OnInit {

    mouseup = new EventEmitter<MouseEvent>();
    mousedown = new EventEmitter<MouseEvent>();
    mousemove = new EventEmitter<MouseEvent>();

    mousedrag: Observable<{top, left}>;

    @HostListener('document:mouseup', ['$event'])
    onMouseup(event: MouseEvent) {
        this.mouseup.emit(event);
    }

    @HostListener('mousedown', ['$event'])
    onMousedown(event: MouseEvent) {
        this.mousedown.emit(event);
        return false; // Call preventDefault() on the event
    }

    @HostListener('document:mousemove', ['$event'])
    onMousemove(event: MouseEvent) {
        this.mousemove.emit(event);
    }

    constructor(public element: ElementRef) {
        this.element.nativeElement.style.position = 'relative';
        this.element.nativeElement.style.cursor = 'pointer';

        this.mousedrag = this.mousedown.map(event => {
            return {
                top: event.clientY - this.element.nativeElement.getBoundingClientRect().top
                left: event.clientX - this.element.nativeElement.getBoundingClientRect().left,
            };
        })
        .flatMap(
            imageOffset => this.mousemove.map(pos => ({
                top: pos.clientY - imageOffset.top,
                left: pos.clientX - imageOffset.left
            }))
            .takeUntil(this.mouseup)
        );
    }

    ngOnInit() {
        this.mousedrag.subscribe({
            next: pos => {
                this.element.nativeElement.style.top = pos.top + 'px';
                this.element.nativeElement.style.left = pos.left + 'px';
            }
        });
    }
}
Cohby answered 29/3, 2016 at 17:10 Comment(7)
there is a problem in the constructor, can anyone correct it?Wylie
Is this a quiz?Cohby
Can we restrict the area of draggable element? Like the element should not move out of the perticular div or something?Parthenope
Calling getBoundingClientRect() 2 times instead of introducing local variable? What an unreasonable way of writing codeAvalokitesvara
@EvAlex, getBoundingClientRect() is a getter function from existing properties (an interface), calling it twice will not impact performance in an specific manner. It seems you've not that much experience with general javascript, please either suggest, or comment in a positive manner; refrain yourself from personal preferences without coding standards to back your subjective insight, specially if you're unable to offer a solution yourself.Poetaster
the problem with this is that as you start to add this directive to more components, the performance will take a hit. The @hostlistener's that are listening to the document are created multiple times which causes it to get fired multiple times even if its not relevant to the element being dragged. The mousemove degrades in performance as it gets fired on every instance of the directive and every pixel move.Pontianak
@Pontianak do you have a better way? What would you do to address that issue?Cohby
J
6

You can use this : npm install ng2draggable

Use [ng2-draggable]="true", don't forget the ="true"

You can find it here

https://github.com/cedvdb/ng2draggable

Here is the code:

@Directive({
  selector: '[ng2-draggable]'
})
export class Draggable implements OnInit{
    topStart:number=0;
    leftStart:number=0;
    _allowDrag:boolean = true;
    md:boolean;

    constructor(public element: ElementRef) {}

        ngOnInit(){
          // css changes
          if(this._allowDrag){
            this.element.nativeElement.style.position = 'relative';
            this.element.nativeElement.className += ' cursor-draggable';
          }
        }

        @HostListener('mousedown', ['$event'])
        onMouseDown(event:MouseEvent) {
          if(event.button === 2)
            return; // prevents right click drag, remove his if you don't want it
          this.md = true;
          this.topStart = event.clientY - this.element.nativeElement.style.top.replace('px','');
          this.leftStart = event.clientX - this.element.nativeElement.style.left.replace('px','');
        }

        @HostListener('document:mouseup')
        onMouseUp(event:MouseEvent) {
          this.md = false;
        }

        @HostListener('document:mousemove', ['$event'])
        onMouseMove(event:MouseEvent) {
          if(this.md && this._allowDrag){
            this.element.nativeElement.style.top = (event.clientY - this.topStart) + 'px';
            this.element.nativeElement.style.left = (event.clientX - this.leftStart) + 'px';
          }
        }

        @HostListener('touchstart', ['$event'])
        onTouchStart(event:TouchEvent) {
          this.md = true;
          this.topStart = event.changedTouches[0].clientY - this.element.nativeElement.style.top.replace('px','');
          this.leftStart = event.changedTouches[0].clientX - this.element.nativeElement.style.left.replace('px','');
          event.stopPropagation();
        }

        @HostListener('document:touchend')
        onTouchEnd() {
          this.md = false;
        }

        @HostListener('document:touchmove', ['$event'])
        onTouchMove(event:TouchEvent) {
          if(this.md && this._allowDrag){
            this.element.nativeElement.style.top = ( event.changedTouches[0].clientY - this.topStart ) + 'px';
            this.element.nativeElement.style.left = ( event.changedTouches[0].clientX - this.leftStart ) + 'px';
          }
          event.stopPropagation();
        }

        @Input('ng2-draggable')
        set allowDrag(value:boolean){
          this._allowDrag = value;
          if(this._allowDrag)
            this.element.nativeElement.className += ' cursor-draggable';
          else
            this.element.nativeElement.className = this.element.nativeElement.className
                                                    .replace(' cursor-draggable','');
        }
}
Jenn answered 13/10, 2016 at 16:53 Comment(9)
Instead of just linking to some code, could you please explain the differences?Cohby
@Cohby it's similar to your code, it doesn't use event emitter and it handles touch events for mobiles as well. Since I wrote it I thought I'd share it here. I added some comment though nowJenn
@Cohby dunno if that's enough ? I also omitted to put the element position on relative the first time I edited. Now it's good.Jenn
@Jenn Do you have any working sample? Demo link - cedvdb.github.io/ng2draggable is not working.Ebullient
@Ebullient Unfortunately no. However I got this cedvdb.github.io/virtual-pixels where if you click on the hand you can then move the cube in the middle. This was a previous version and it was buggy, but you get the gist. Alternatively just copy paste the code and try. The version that is on npm is not the right one so if you tried that one you might have had issues.. I tried the code above right now and it worksJenn
Yes @Jenn your code is working +1. By mistake I used <div ng2-draggable> instead of <div [ng2-draggable]=true>Ebullient
@Ebullient Yeah, I noticed that I need to edit the github readmeJenn
great stuff. we might need to tweak so that users cannot drag and drop the bar hidden behind browser's window as you have no way to grab the bar again to drag it around ;)Portraitist
This one works out of the box! Great stuff @Ced, I used it in an Angular 5 project of minePoetaster
T
2

I have same problem with draggable popup, so I add mousemove and mouseup events to document on mousedown, and remove them on mouseup. I use Eric Martinez's answer for add and remove event listener dynamically.

Template:

<div class="popup-win" (mousedown)="mousedown($event)"></div>

Component:

constructor(private elementRef: ElementRef,
        private renderer: Renderer2) {}

mousedown(event: any) {
    this.xStartElementPoint = this.curX;
    this.yStartElementPoint = this.curY;
    this.xStartMousePoint = event.pageX;
    this.yStartMousePoint = event.pageY;
    this.mousemoveEvent = this.renderer.listen("document", "mousemove", this.dragging);
    this.mouseupEvent = this.renderer.listen("document", "mouseup", this.mouseup);
}

dragging(event: any) {
     this.curX = this.xStartElementPoint + (event.pageX - this.xStartMousePoint);
     this.curY = this.yStartElementPoint + (event.pageY - this.yStartMousePoint);
}
mouseup(event: any) {
    // Remove listeners
    this.mousemoveEvent();
    this.mouseupEvent();
}

Here's a runnable example on Plunker.

Tnt answered 2/10, 2017 at 10:46 Comment(5)
Took me forever to find sth good, the plunker works great and I could embed it in my project, thanks!Nader
Infinite "loading..." for me on this plunk !Philosophism
Tank you @Curse. It depended to Plunker resource link. I fixed it.Tnt
Thank you @Curse. Please try it again.Tnt
@Tnt it looks working with the new version of Plunker (url: next.plnkr.co/edit/AoaK7z?p=preview&preview) but not with the old !Philosophism
P
1

You could create a large div that covers the screen real estate. To start with this div has a lower z-index than the div you want to drag. On receiving mousedown you change the z-index of the div to be higher than the drag-element and receive mouse move events on this div. You could the n use that to compute the position of the drag-element. You can then stop and send the div back again when you receive a mouse up.

I have recently written a modular drag and drop framework in Angular2. Please give it a try and provide feedback.

https://github.com/ivegotwings/ng2Draggable

However, I stop the drag once the mouseout event is fired.

Psalter answered 29/3, 2016 at 2:1 Comment(2)
.takeUntil(this._mouseout).takeUntil(this._mouseup); This is exactly the problem I'm trying to solve. I don't want to stop the drag on mouseout events. As long as the mouse button is down, it shouldn't matter where the user moves the mouse, the drag should continue.Cohby
Hey Chris, The mouse move events that are used to compute positions are only received as long as you are on that div. I have updated my answer for a work around.Psalter
M
0

The solution proposed by @ali-myousefi was promising, but not working (for a number of reasons). I spent some hours trying to get it work, and here is the result:

Template:

<div (mousedown)="mousedown($event)">a draggable block</div>
[…]
<div (mousedown)="mousedown($event)">another draggable block</div>

Component:

import { Component, Renderer2 } from '@angular/core';

@Component({
  selector: 'app-foobar',
  templateUrl: './foobar.component.html',
  styleUrls: ['./foorbar.component.scss'],
})
export class FoobarComponent {

  mousemoveEvent: any;
  mouseupEvent: any;

  movedElement: any;

  curX: number;
  curY: number;
  offsetX = 0;
  offsetY = 0;
  xStartElementPoint: number;
  yStartElementPoint: number;
  xStartMousePoint: number;
  yStartMousePoint: number;

  constructor(private renderer: Renderer2) {}

  mousedown(event: MouseEvent) {
    this.movedElement = this.renderer.selectRootElement(event.target, true);
    this.setInitialAbsolutePos(this.movedElement);
    this.xStartElementPoint = this.curX;
    this.yStartElementPoint = this.curY;
    this.xStartMousePoint = event.pageX;
    this.yStartMousePoint = event.pageY;
    this.mousemoveEvent = this.renderer.listen("document", "mousemove", this.dragging.bind(this));
    this.mouseupEvent = this.renderer.listen("document", "mouseup", this.mouseup.bind(this));

    return false; // Call preventDefault() on the event
}

  dragging(event: any) {
    this.curX = this.xStartElementPoint + (event.pageX - this.xStartMousePoint + this.offsetX);
    this.curY = this.yStartElementPoint + (event.pageY - this.yStartMousePoint + this.offsetY);
    this.moveElement(this.movedElement, this.curX, this.curY);

    return false; // Call preventDefault() on the event
  }

  mouseup(event: any) {
    // Remove listeners
    this.mousemoveEvent();
    this.mouseupEvent();

    return false; // Call preventDefault() on the event
  }

  moveElement(element, curX, curY) {
    // update the position of the div:
    this.renderer.setStyle(element, 'left', curX + 'px');
    this.renderer.setStyle(element, 'top', curY + 'px');
    this.renderer.setStyle(element, 'right', 'initial'); // required in case the element was previously right-aligned...
    this.renderer.setStyle(element, 'bottom', 'initial'); // required in case the element was previously bottom-aligned...
  }

  setInitialAbsolutePos(element: any) {
    this.curX = element.getBoundingClientRect().left;
    this.curY = element.getBoundingClientRect().top;

    // set position:absolute (if not already done)
    this.renderer.setStyle(element, 'position', 'absolute');

    // compensate for the new position:absolute
    // and/or padding / margin / borders (if present)
    // by making a move of 0 pixels and then compute the offset:
    this.moveElement(element, this.curX, this.curY);
    const afterX = element.getBoundingClientRect().left;
    const afterY = element.getBoundingClientRect().top;
    this.offsetX = (this.curX - afterX);
    this.offsetY = (this.curY - afterY);
    if (this.offsetX != 0 || this.offsetY != 0) {
      this.moveElement(element, this.curX + this.offsetX, this.curY + this.offsetY);
    }
  }

}

Advantages:

  • works even if there are margins, paddings, borders, etc.
  • works even if the element wasn't previously in position:absolute
  • one single code that can be used for making several different elements draggable
  • no need to put an id='' on each element
  • no additionnal library required
Miscarry answered 6/3, 2023 at 19:7 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.