Dieser Artikel ist Teil einer Serie:
Die API, die HTML5 für Drag & Drop spezifiziert, gilt gemeinhin als ziemlich missraten. Die Schlimmheit fällt umso stärker aus, je komplexer der Use Case wird, was zur Folge hat, dass das in Teil 1 besprochene Drag & Drop für Dateien noch vergleichsweise simpel war. Auch das Ziehen von Elementen gestaltet sich nicht völlig katastrophal, ist aber ein wichtiger Schritt auf dem Weg in die wirklich üblen Untiefen der API, inklusive erster richtiger Internet-Explorer-Probleme.
Vorbereitungen
Unser Beispiel soll darin bestehen, dass es zwei Dropzonen gibt, in die der Nutzer via Drag & Drop andere Elemente hineinziehen können soll. Dabei soll jedes ziehbare Element nur in eine bestimmte Dropzone hineinpassen. Wie im ersten Teil können uns ganz normale <div>
-Elemente als Container dienen, die auch die gleichen Styles wie bisher erhalten:
<div id="Markup" class="dropzone">Markup</div> <div id="Style" class="dropzone">Style</div>
.dropzone { text-align: center; background: #EEE; border: 0.2em solid #000; padding: 3em; margin: 0.5em 0; } .dropzone.valid { background: #EFE; border: 0.2em solid #0F0; }
Als ziehbare Elemente nehmen wir normale <li>
mit etwas CSS:
<ul> <li>HTML5</li> <li>CSS3</li> <li>XHTML 1</li> <li>JavaScript</li> </ul>
ul { list-style: none; margin: 0; padding: 0; } li { background: #EEE; border: 0.2em solid #000; padding: 0.5em; margin: 0.5em 0; }
Außerdem können wir unserem Datei-Drop-Beispiel noch einige Zeilen JavaScript übernehmen. Das Lösen der Drop-Handbremse durch die dragover
- und dragenter
-Event sowie die Vergabe der Klassen brauchen wir auch diesmal:
$('.dropzone').on('dragover', function(evt){ evt.preventDefault(); }); $('.dropzone').on('dragenter', function(){ $(this).addClass('valid'); evt.preventDefault(); }); $('.dropzone').on('dragleave', function(){ $(this).removeClass('valid'); }); $('.dropzone').on('drop', function(evt){ evt.preventDefault(); $(this).removeClass('valid'); });
Soweit, so gut (siehe jsFiddle).
Elemente ziehbar machen
Wie in Teil 1 erwähnt, wurde die HTML5-API aus dem IE5 reverse engineered. Dort, sowie auch in allen anderen Browsern, die sie zuvor implementiert hatten, diente Sie zum Abwickeln aller Drag & Drop-Operationen – also auch jener, die ganz ohne JavaScript zustande kommen. Markiert man etwas Text und zieht ihn durch die Gegend oder zieht man ein <a>
irgendwo hin, so ist die exakt gleiche Logik wie in der HTML5-API am Werke. Das kann man sehr schön sehen, indem man in der Fiddle einfach irgendwelchen Text (z.B. ein paar Buchstaben aus den Labels der ziehbaren Elemente) markiert und ihn auf eine der Dropzonen zieht; die Zonen leuchten grün auf, weil auch die nativen Drag-Operationen von der API verarbeitet werden können.
Um auch andere Elemente neben <a>
ziehbar zu machen, verpasst man ihnen das Attribut draggable="true"
:
<ul> <li draggable="true">HTML5</li> <li draggable="true">CSS3</li> <li draggable="true">XHTML 1</li> <li draggable="true">JavaScript</li> </ul>
Dieses Attribut wurde im Zuge der HTML5-Entwicklung spontan erfunden, um überhaupt irgendwas anderes als Links und Bilder ziehbar machen. Das draggable
-Attribut kann auch genutzt werden, um <a>
-Elementen die Drag-Funktionalität entziehen, indem man es auf false
setzt. Um das Ganze noch schön zu machen, bietet es sich an, den ziehbaren Elementen auch einen passenden Mauscursor zu verpassen:
[draggable=true] { cursor: move; }
Die Drag-Operation kann durch drei Events via JavaScript verfolgt werden: dragstart
feuert beim Start einer Drag-Operation auf dem gezogenen Element, drag
findet laufend während einer Operation statt und dragend
feuert, wenn eine Drag-Operation endet. Also noch fix ein wenig JS eingebaut …
$('li').on('dragstart', function(evt){ console.log('Start'); }); $('li').on('drag', function(evt){ console.log('Drag'); }); $('li').on('dragend', function(evt){ console.log('Ende'); });
… und schon funktioniert das Ziehen von Elementen! Jedenfalls funktioniert es (Stand Anfang 2014) in Chrome; der Firefox weigert sich, Drag & Drop ohne Daten durchzuführen, was (wenn auch durch die Spezifikationen nicht abgedeckt) verständlich ist; im Moment ziehen wir nur Elemente durch die Gegend, ohne dass wir irgendwelche sinnvollen Informationen übermitteln würden. Außerdem können wir aktuell jedes ziehbare Element in jede Dropzone ziehen. Das kann so nicht bleiben – wir brauchen Daten für unsere Drag-Operation.
Daten in Drag & Drop
Eins der Probleme mit Drag & Drop-Daten ist, dass die HTML5-Spezifikationen zwei APIs beschreiben: das gruselige Original aus dem alten IE und eine nicht ganz so gruselige Variante, die bisher nur teilweise in aktuellen Browsern zu finden ist. Das Grundprinzip ist jeweils gleich: beim Start einer Drag-Operation (beim dragstart
-Event) schreibt man die zu übertragenden Daten das Event-Objekt. Der Browser merkt sich diese Daten und stellt sie am Ende einer Drag-Operation wieder in einem Event-Objekt zur Verfügung, so dass sich herausfinden lässt, welche Informationen genau durch die Gegend gezogen wurden. Um unser Script in mehr als einem Browser lauffähig zu bekommen, müssen wir uns Anno 2014 noch mit der alten Grusel-API abfinden. Und auf den ersten Blick scheint es auch gar nicht so schlimm zu sein.
Das Datenspeicher-Objekt heißt dataTransfer
und ist ein Unterobjekt des Event-Objekts; in den Fiddle-Beispielen ist es dank jQuery unter evt.originalEvent.dataTransfer
zu finden. Das Datenspeicher-Objekt hat die Methoden setData(type, data)
und getData(type)
, die zum Setzen und Lesen von Daten genutzt werden können. Das Ganze funktioniert wie ein simpler Key-Value-Speicher für Strings. Auf unser Beispiel aufbauend könnten wir nun beim dragstart
-Event Daten setzen und sie beim drop
-Event wieder auslesen:
$('li').on('dragstart', function(evt){ evt.originalEvent.dataTransfer.setData('text', 'Hallo Welt!'); }); $('.dropzone').on('drop', function(evt){ evt.preventDefault(); window.alert(evt.originalEvent.dataTransfer.getData('text')); });
Und schon funktioniert‘s sogar im Firefox! Allerdings funktioniert es auch nur auf exakt diese Art und Weise, denn der Datenspeicher ist nicht bei jedem Drag & Drop-Event les- und beschreibbar. Laut Spezifikationen bestehen Schreibrechte ausschließlich beim dragstart
-Event und Leserechte nur beim drop
-Event. Während aller anderen Event-Phasen kann weder gelesen noch geschrieben werden. Das klingt im ersten Moment sinnvoll, hat aber einen Haken: ob ein Ziel-Element ein valides Drop-Ziel ist, bestimmen wir im dragover
-Event durch ein preventDefault()
– das schon besprochene Lösen der eingebauten Handbremse. Im Moment können wir jedes <li>
-Element auf jedes Ziel-<div>
ziehen, auch das CSS3-Item in die Markup-Box. Um das zu unterbinden, müssten wir bei dragover
in die Daten hineinschauen können, was so richtig nicht geht. Aber immerhin halb! Und nur außerhalb der modernen Internet Explorer. Aber immerhin!
Gezieltes Drag & Drop
In den Event-Phasen neben dragstart
und drop
kommt man an die Daten im dataTransfer
-Objekt nicht heran, wohl aber an die Keys unter denen sie gespeichert sind – und die Spezifikationen sehen vor, dass man den Key frei wählen kann. Also vergeben wir doch einfach je nach Kategorie des gezogenen Elements unterschiedliche Keys. Mit Data-Attributen nehmen wir im Markup die Kategorisierung vor …
<div data-accept="markup" id="Markup" class="dropzone">Markup</div> <div data-accept="style" id="Style" class="dropzone">Style</div> <ul> <li data-type="markup" draggable="true">HTML5</li> <li data-type="style" draggable="true">CSS3</li> <li data-type="markup" draggable="true">XHTML 1</li> <li data-type="script" draggable="true">JavaScript</li> </ul>… und speichern Daten mit dem Wert des
data-type
-Attributs der gezogenen Elemente als Key:
$('li').on('dragstart', function(evt){ console.log('Start'); var type = $(this).attr('data-type'); var data = $(this).text(); evt.originalEvent.dataTransfer.setData(type, data); });
Die Handbremse zielgerichtet zu lösen scheint auch beinahe einfach zu sein. Im dragover
-Event, wo dies passieren müsste, haben wir zwar keinen Zugriff auf den Inhalt von dataTransfer
, können aber unter dataTransfer.types
die Keys abfragen. Ein Problem hieran ist, dass einige Browser diese Key-Liste (standardkonform) als Array anbieten, andere (ehemals standardkonform) als DOMStringList. Arrays haben eine indexOf()
-Methode, die DOMStringLists fehlt, während DOMStringLists eine contains()
-Methode haben, die Arrays fehlt. Sobald man diese Differenz irgendwie unter Kontrolle gebracht hat, scheint der restliche Weg ganz leicht zu sein:
- Bei
dragstart
Daten unter einem Key setzen, der dem Wert desdata-type
-Attributs des gezogenen Elements entspricht - Bei
dragover
schauen, ob Daten unter einem Key vorhanden sind, der dem Wert desdata-accept
-Attributs des Ziel-Elements entspricht; wenn ja, mitpreventDefault()
die Handbremse lösen - Bei
drop
, das nur stattfindet wenn vorher indragover
einpreventDefault()
passiert ist, die eigentlichen Daten auslesen
Den Code für Schritt 1 haben wir breits gesehen. Schritt 2, das dragover
-Event, ist auch ganz simpel wenn man erst mal ein gemeinsames indexOf()
DOMStringLists und Arrays gebaut hat:
// Universelles indexOf() für DOMStringLists, Arrays und mehr var indexOf = Function.prototype.call.bind(Array.prototype.indexOf); $('.dropzone').on('dragover', function(evt){ var accept = $(this).attr('data-accept'); // Entspricht ein Key unserem data-accept-Attribut? if(indexOf(evt.originalEvent.dataTransfer.types, accept) !== -1){ evt.preventDefault(); // Drop erlauben! } });
Am Ende ist es ein leichtes, mittels dataTransfer.getData()
die übertragenen Daten auszulesen und anzuzeigen:
$('.dropzone').on('drop', function(evt){ evt.preventDefault(); $(this).removeClass('valid'); var key = $(evt.target).attr('data-accept'); var val = evt.originalEvent.dataTransfer.getData(key); window.alert(val + ' ist ' + key); });
Und schon klappt es … jedenfalls in Chrome, Opera, Safari und Firefox. In modernen Internet Explorern funktioniert das Script so nicht und eigentlich ist es auch noch nicht so ganz richtig rund.
Lücken, Macken und Internet Explorer
Nur weil Drag & Drop für HTML5 aus dem Internet Explorer reverse engineered wurde, funktioniert es dort noch lange nicht. Grundsätzlich wird die API natürlich schon unterstützt, nur unser Key-Differenzierungs-Trick funktioniert nicht. Die modernen IE akzeptieren ausschließlich text
und url
als Keys bei dataTransfer.setData()
– und das, obwohl der Key-Trick sogar im offiziellen Spezifikations-Tutorial benutzt wird. Das ist etwas unglücklich, aber keine Vollkatastrophe; das einzige, was im IE definitiv nicht geht, ist die Unterscheidung zwischen verschiedenen Sorten von Element-Drag-Operationen. Wenn man nur eine solche Operation auf einer Seite hat (z.B. eine einzige umsortierbare Liste oder eine einzige Dropzone für Dateien) gibt es kein Problem.
Interessanter ist da schon ein noch fehlendes Feature unserer Beispiel-Fiddle. Zwar können wir Elemente von A nach B ziehen und auch sicherstellen, dass A nur in B und nicht in C abgeladen werden kann aber eins können wir nicht: A darüber informieren, dass es nach B gezogen wurde. Möglicherweise möchte man das Element A aus dem DOM verschwinden lassen wenn es irgendwohin gezogen wurde oder in ein anderes Element einsortieren. Das Problem ist, dass wir erst nach dem drop
-Event wissen, ob eine Drag-Operation mit einem erfolgreichen Drop geendet ist– das drop
-Event feuert allerdings auf dem Ziel-Element B, nicht auf dem gezogenen Element A. Zwar gibt auch das dragend
-Event, das wie gewünscht auf A feuert, das allerdings immer tut auch wenn es gar kein Drop gab. Die gute Nachricht ist: mit ein bisschen Mißbrauch alter IE-Features und der Kunst des indirekten Nachweises ist das Problem in den Griff zu bekommen. Wie genau das geht sehen wir im nächsten Teil dieser Serie.
Kommentare (3)
Paul ¶
18. März 2014, 14:39 Uhr
Danke für den ausführlichen Artikel!
Eine Frage habe ich noch. Ist es eigentlich auch möglich, ein "Handle" für ein ziehbares Element zu definieren? Soll heißen: Ein Element kann durch eine "griffige" Stelle, also ein Kindelement oder ein Nachbarelement gezogen werden. Das ist beispielsweise sinnvoll, wenn man ein editierbares Element ziehbar machen möchte. In jQuery UI gibt es eine vergleichbare Möglichkeit. Mit HTML5 D&D konnte ich es bisher noch nicht nachbauen.
Peter Kröner ¶
18. März 2014, 15:43 Uhr
Gute Frage.
Erster Gedanke: Event Delegation. Das klappt aber nicht, denn das
dragstart
-Event würde ja nur auf dem Handle getriggert werden, wenn es selbstdraggable="true"
hätte – und das kann es nicht haben, denn sonst würden wir ja das Handle ziehen und nicht das Element.Zweiter Gedanke: Beim
dragstart
-Event mit Koordinaten-Verrechnerei nachschauen, ob der Cursor beim Triggern des Events auf dem Handle lag. Falls das nicht der Fall ist, einfach das Event und damit die ganze Operation mitevt.preventDefault()
abbrechen. Das funktioniert!Dann ist mir aber noch was anderes eingefallen:
Das ist zwar etwas billig gehackt, klappt aber auch!
Alle Varianten in Chrome, Firefox und modernem IE getestet.
Paul ¶
18. März 2014, 17:43 Uhr
Danke! Sieht gut aus. :)