D3.js für engagierte Angular-Entwickler, Teil 5: Unser individueller Ghost beim Drag and Drop

Drag and Drop mit Angular und D3.js-Animationen: Ein nützlicher Ghost
Keine Kommentare

Weiter geht’s mit Angular und D3.js: Im ersten Teil zu Drag and Drop mit Angular haben wir es bereits geschafft, unsere Elemente zu verschieben und über unserer Landing-Zone wieder fallen zu lassen – und das Browser-kompatibel! Nun schauen wir uns gemeinsam an, wie wir unser eigenes Ghost-Element für die Drag-and-Drop-Operation Browser-übergreifend implementieren können.

Der Browser erzeugt standardmäßig bereits ein Abbild unseres Drag-Elements, den so genannten Ghost. Im Prinzip ist dies ja nicht verkehrt. Aber wenn wir das Element loslassen – unabhängig davon, ob die Drag-Operation abgebrochen oder ausgeführt wird – verschwindet der Ghost sofort. Beim Ausführen wird unser Endelement auch direkt eingeblendet. Wir wollen aber animierte Übergänge erzeugen und den Ghost später gegebenenfalls optisch verändern können. Aber vor allem soll dieser in allen Browsern identisch aussehen.

Drag-Element und Standard-Browser Ghost ausblenden

Um das zu erreichen, müssen wir es zunächst schaffen, den Standard-Ghost und unser eigentliches Element auszublenden. Was passiert, wenn wir das Element direkt beim Start des Drag-Vorgangs durch die CSS-Eigenschaft visibility: hidden ausblenden?

D3.js für engagierte Angular-Entwickler – die Artikelserie

Teil 1: Bubble-Charts
Teil 2: Pie-Charts
Teil 3: Animierte Diagramme
Teil 4: Drag & Drop mit Angular
Teil 5: Drag & Drop mit eigenem Ghost
Teil 6: Drag & Drop mit Animation in D3.js

Probieren wir es aus und erweitern wir zunächst unser Interface Item um die optionale Eigenschaft visible.

interface Item {
  text: string;
  color: string;
  left: number;
  top: number;
  visible?: boolean;
}

In unserem Ereignis dragstart setzen wir den Eigenschaftswert für visible auf false um das Element auszublenden.

public dragStart(event: DragEvent, item: Item){
  event.dataTransfer.setData('text', item.text);
  event.dataTransfer.effectAllowed = 'move';
  this.dragging = item;
  item.visible = false;
};

In unserem HTML-Template müssen wir noch auf den neuen Eigenschaftswert entsprechend reagieren:

<div *ngFor="let item of draggable" 
  class="draggable-item"
  [style.left]="item.left+'px'"
  [style.top]="item.top+'px'"
  [style.background-color]="item.color"
  [style.visibility]="item.visible!==false ? 'visible' : 'hidden'"
  draggable="true"
  (dragstart)="dragStart($event, item)"
  (dragend)="dragEnd($event, item)"
>
  {{ item.text }}
</div>

Je nach dem mit welchem Browser wir uns das Ergebnis ansehen, erwarten uns unterschiedliche Ergebnisse. Im Internet Explorer und im Firefox wird der Ghost wie erwartet ausgeblendet, im Chrome ebenfalls. Dort wird aber unser Drag-Vorgang abgebrochen. Und ich dachte die Zeiten der Browserweichen wären irgendwann vorbei… Chrome benötigt zum Zeitpunkt des Drag-Starts ein im ViewPort sichtbares Element. Glücklicherweise stellt uns Chrome eine weitere Methode zur Verfügung, mit der wir dieses Problem lösen können – die setDragImage-Methode, die auch in Firefox implementiert ist. Diese Methode erwartet ein sichtbares HTML-Element als ersten Parameter. Zusätzlich lässt sich eine Verschiebung des neuen Ghost vom Mauszeiger definieren – da wir aber eigentlich die Browser-Ghost-Funktionalität gar nicht nutzen wollen, muss uns nur der erste Parameter interessieren. Darum erzeugen wir ein, für den Browser „sichtbares“ und für den Nutzer unsichtbares HTML-Element mit absoluter Positionierung, 1×1 Pixel Größe und ohne weitere Formatierungen.

<div class="drag-dummy"></div>
.drag-dummy {
  position: absolute;
  width: 1px; height: 1px; 
}

Dieses HTML-Element referenzieren wir und übergeben die Referenz an die setDragImage-Methode. Dafür benötigen wir zunächst den Zugriff auf unser HTML-Element der Angular-Komponente. Erweitern wir den Konstruktor wie folgt:

constructor(
  private element: ElementRef
){}

Nun können wir auf unser Dummy-Element zugreifen und dieses verwenden. Da der Internet Explorer aber die Methode setDragImage gar nicht kennt, müssen wir zunächst prüfen ob diese im jeweilige Browser registriert ist – andernfalls hagelt es Ausnahmen.

public dragStart(event: DragEvent, item: Item){
  event.dataTransfer.setData('text', item.text);
  event.dataTransfer.effectAllowed = 'move';
  this.dragging = item;
  if(event.dataTransfer.setDragImage){
    const dragDummy = 
      (this.element.nativeElement as Element).querySelector('.drag-dummy');
    event.dataTransfer.setDragImage(dragDummy, 0, 0);
  }
  else {
    item.visible = false;
  }
};

Wir sehen nun in allen Browsern, dass kein Ghost-Element mehr angezeigt wird. Aber unser eigenes Element wird aktuell nur im Internet Explorer ausgeblendet – in Firefox und Chrome ist es nach wie vor sichtbar. Wir wissen ja bereits, dass wir das Element nicht einfach ausblenden können, da sonst der Drag-Vorgang in Chrome abgebrochen wird. Wir können uns aber mit einem kleinen Trick behelfen. Sobald der Drag-Vorgang gestartet ist, benötigt der Browser das sichtbare Element nicht mehr. Wir müssen somit das Ausblenden einfach auf den nächsten Rendering-Zyklus verschieben. Und das erreichen wir ganz einfach über eine setTimeout-Closure.

public dragStart(event: DragEvent, item: Item){
  event.dataTransfer.setData('text', item.text);
  event.dataTransfer.effectAllowed = 'move';
  this.dragging = item;
  if(event.dataTransfer.setDragImage){
    const dragDummy = 
      (this.element.nativeElement as Element).querySelector('.drag-dummy');
    event.dataTransfer.setDragImage(dragDummy, 0, 0);
    setTimeout( () => { item.visible = false; }, 0);
  }
  else {
    item.visible = false;
  }
};

Wenn wir den Drag-Vorgang abschließen, funktioniert alles einwandfrei, aber wenn wir ihn abbrechen, dann bleibt unser Element ausgeblendet. Wir wissen ja inzwischen, dass beim Abbruch das Ereignis dragend ausgelöst wird. Also blenden wir unser Element dort einfach wieder ein.

public dragEnd(event: DragEvent, item: Item){
  item.visible = true;
  this.dragging = undefined;
};

Geschafft! Und ich dachte, das Aufwändigste kommt erst noch…

Unser eigenes Ghost-Element erstellen

Nun erweitern wir unser HTML-Template um unser eigenes Ghost-Element, das immer während eines Drag-Vorgangs eingeblendet werden soll und später für animierte Übergänge verwendet wird.

<div *ngIf="dragging"
  class="dragging-item"
  [style.background-color]="dragging.color"
>
  {{ dragging.text }}
</div>

Außerdem ergänzen wir noch ein paar CSS-Styles, damit unser neues Ghost-Element zunächst wie unser eigentliches Element aussieht. Machen wir es uns einfach und verwenden gleich dieselbe CSS-Regel:

.draggable-item, .dragging-item {
  position: absolute;
  width: 80px;
  height: 80px;
  border-radius: 50%;
  text-align: center;
  line-height: 80px;
}

Wenn wir das Ergebnis nun im Browser betrachten, sieht das Ghost-Element schon mal so aus wie unser eigentliches Element, aber die Position stimmt irgendwie noch nicht wirklich. Darum erzeugen wir zunächst ein Interface für unsere zuzuweisende Position.

interface DragItemStyle {
  left: number;
  top: number;
}

Anschließend definieren wir dieses Interface als Eigenschaft in unserer Komponente.

public dragItemStyle: DragItemStyle;

Im Ereignis dragstart können wir nun die Position unseres Elements berechnen und für das Ghost-Element sichern. Dazu greifen wir einfach auf die zum Element gespeicherte Positionierung zurück.

public dragStart(event: DragEvent, item: Item){
  ...
  this.dragItemStyle = {
    left: item.left,
    top: item.top
  };
};

Anschließend erweitern wir noch unser HTML-Template entsprechend.

<div *ngIf="dragging"
  class="dragging-item"
  [style.background-color]="dragging.color"
  [style.left]="dragItemStyle.left+'px'"
  [style.top]="dragItemStyle.top+'px'"
>
  {{ dragging.text }}
</div>

Und wieder: Im Internet Explorer und Firefox funktioniert alles tadellos. In Chrome passiert wieder mal gar nichts – nicht mal unser Drag-Vorgang bleibt erhalten. Unser Element verschwindet und taucht nie wieder auf. Ironischerweise wird das Ereignis dragend im Chrome ausgeführt – okay, aber müsste dann nicht eigentlich unser Element wieder sichtbar werden? Theoretisch schon, aber das Ereignis dragend wird direkt nach dem Ereignis dragstart ausgelöst, ohne vorher ein Rendering durchzuführen. Wir erinnern uns, da war doch was…

Jetzt könnten wir natürlich einfach das Ausblenden des Elementes ebenfalls in eine setTimeout-Closure verschieben. Das würde tatsächlich dazu führen, dass unser Element wieder sichtbar wird. Der Drag-Vorgang würde aber nach wie vor abgebrochen werden. Wir müssen verhindern, dass Chrome direkt das Ereignis dragend auslöst. Bei genauerer Untersuchung stellen wir fest, dass das Einblenden des neuen Elements (nämlich unserem Ghost) direkt an der Position des alten Elements zu diesem Problem führt. Wie können wir verhindern, dass der Browser auf das neue Element reagiert? In dem wir für das neue Element – unserem Ghost – die CSS-Eigenschaft pointer-events: none ergänzen.

.dragging-item {
  pointer-events: none;
}

Wir haben nun dafür gesorgt, dass wir das originale Element ausblenden und genau an derselben Stelle eine 1:1 Kopie wieder darstellen – viel Aufwand mit zunächst wenig Nutzen. Aber so ist das Leben als Entwickler leider ab und zu. Gehen wir lieber schnell zum nächsten Schritt über, ohne groß darüber nachzudenken.

Unser Ghost-Element mit der Maus verschieben

Eigentlich wollen wir ja, dass sich unser Ghost-Element mit der Maus bewegt – also ähnlich wie der originale Browser-Ghost, nur halt „schöner“. Während eines Drag-Vorgangs werden leider keine Maus-Ereignisse wie mousemove ausgelöst. Schade, das wäre meine erste Wahl gewesen. Aber möglich sein muss das doch trotzdem irgendwie.

Wir hatten bereits das Ereignis dragover kennen gelernt, welches fortlaufend ausgelöst wird, während das Element über unserer Landezone verschoben wird. Über unserer Landezone bringt uns das natürlich für die Positionierung nicht viel, aber wir können das Ereignis zusätzlich auf unserem Container für die Positionierung definieren.

<div class="drag-container"
  (dragover)="moveDragElement($event)"
>

Innerhalb des Ereignisses dragover können wir auf die aktuelle Mausposition über die Eigenschaften eventX und eventY zugreifen. Positionieren wir das Element zunächst einfach über diese Koordinaten. Auch hier müssen wir wieder berücksichtigen, dass nicht nur wir innerhalb des Browsers den Drag-Vorgang auslösen können und unser Style-Objekt eventuell noch gar nicht existiert. Dadurch, dass wir das Standard-Verhalten des Browsers nicht über die Methode preventDefault verhindern, reagiert unser Container auch nicht auf den Drag-Vorgang und bietet sich somit nicht als mögliches Ziel an.

public moveDragElement(event: DragEvent){
  if(this.dragging){
    this.dragItemStyle.left = event.pageX;
    this.dragItemStyle.top = event.pageY;
  }
};

Wir sehen nun, wie unser Ghost-Element schön der Maus folgt. Leider verspringt das Element direkt beim Starten des Drag-Vorgangs. Da wir natürlich das Element selten direkt an der oberen linken Ecke erwischen – in unserem Fall durch den abgerundeten Rahmen sogar nie – befindet sich die Maus beim Auslösen des Drag-Vorgangs immer irgendwo innerhalb des Elements. Wir müssen sozusagen zusätzlich die Position der Maus beim Starten des Drag-Vorgangs innerhalb unseres Elementes berechnen und als Offset für die Verschiebung speichern. Definieren wir zunächst eine Eigenschaft für diesen Offset.

public mouseOffset = { x: 0, y: 0 };

Innerhalb unseres Ereignis dragstart berechnen wir nun die Differenz zwischen Element-Position und Maus-Position und speichern diese als unseren Offset für die Verschiebung.

public dragStart(event: DragEvent, item: Item){
  ...
  this.mouseOffset.x = event.pageX - item.left;
  this.mouseOffset.y = event.pageY - item.top;
};

In unserer moveDragElement-Methode ziehen wir nun einfach den gespeicherten Offset von den Maus-Koordinaten ab.

public moveDragElement(event: DragEvent){
  if(this.dragging){
    this.dragItemStyle.left = event.pageX - this.mouseOffset.x;
    this.dragItemStyle.top = event.pageY - this.mouseOffset.y;
  }
};

Fazit und Ausblick

Wir sehen nun das unser Ghost-Element dem Mauszeiger folgt – genau das, was wir erreichen wollten. Wir haben auch unsere Drag and Drop Funktionalitäten implementiert und alles funktioniert in allen moderneren Browsern. Wir haben schon viel geschafft. Im nächsten Teil widmen wir uns den Animationen beim Drag and Drop.

Unsere Redaktion empfiehlt:

Relevante Beiträge

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu:
X
- Gib Deinen Standort ein -
- or -