Drag and drop en html et javascript

Bonjour,

Aujourd’hui j’ai du implémenter un outil pour réorganiser une arborescence.

Il y a donc une imbrication de <ul> (et les <li> qui vont avec) et l’utilisateur pourra bouger n’importe quelle partie de l’arborescence.

Mise en place basique

Voici un exemple d’arborescence en html :

<!DOCTYPE html>
<html>
    <head>
        <title>Drag and drop</title>
    </head>
    <body>
        <ul>
            <li>Element 1</li>
            <li>Element 2</li>
            <li>Element 3
                <ul>
                    <li>Element 4</li>
                </ul>
            </li>
        </ul>
    </body>
</html>

Il faut rendre les <li> draggable avec l’attribut draggable= »true »

Ensuite faire une balise <script>, de préférence en fin de page, avant </body>.

On y mettra les fonctions pour le drag and drop (drag pour ondragstart et drop pour ondrop)

function drag(ev) {
}
function drop(ev) {
    ev.preventDefault();
}
function dragOver(ev) {
    ev.preventDefault();
}

Ceci ne suffit pas, il faut affecter ces fonctions aux évènements des éléments concernés. Mais on va faire ça bien, c’est à dire dans une fonction init qui se lancera dès que le dom est prêt.

function init() {
    // attache drop
    elements = document.querySelectorAll('li');
    for (i = 0 ; i < elements.length ; i++) {
        elements[i].addEventListener('drop', drop);
        elements[i].addEventListener('dragover', dragOver);
    }
    // attache drag
    elements = document.querySelectorAll('li[draggable]');
    for (i = 0 ; i < elements.length ; i++) {
        elements[i].addEventListener('dragstart', drag);
    }
}
document.addEventListener('DOMContentLoaded', init);

J’attache drop avant drag pour du chipotage, je donnerais la réponse en commentaire si ça intéresse quelqu’un. En plus, j’attache drag seulement aux éléments draggable et drop à tous les li, juste pour montrer que c’est pas forcément en double sens.

On y est presque, il faut maintenant détacher l’élément source et l’attacher dans l’élément cible. Pour cela, il faut générer un id dans les éléments draggable si ils en ont pas déjà un.

// attache drag
elements = document.querySelectorAll('li[draggable]');
for (i = 0 ; i < elements.length ; i++) {
    id = elements[i].getAttribute('id');
    if (id == null || id == '') {
        elements[i].setAttribute('id', 'draggable'+i);
    }
    elements[i].addEventListener('dragstart', drag);
}

Maintenant que tout est en place, on peut donner les actions de déplacement dans le dom.

function drag(ev) {
    element = ev.target;
    ev.dataTransfer.setData('text', element.getAttribute('id'));
}
function drop(ev) {
    ev.preventDefault();
    data = ev.dataTransfer.getData('text');
    element = document.getElementById(data);
}

Ca marche mais une fois ou deux, ce code, il y a plein de bogue !

Amélioration et hierarchie

On va voir ce qui se passe et corriger. On met directement un <li> dans un autre <li>, il faudrait construire un <ul> et mettre notre <li> dedans, comme ça on aura corrigé le problème et on peut avoir une hiérarchie.

function drop(ev) {
    ev.preventDefault();
    data = ev.dataTransfer.getData('text');
    element = document.createElement('ul');
        element2 = document.getElementById(data);
        parent = element2.parentElement;
        element.appendChild(element2);
    ev.target.appendChild(element);
}

Ca marche bien maintenant. Mais quand je lâche un élément sur lui même, il disparaît.

Empêcher la source d’être la cible

Ce petit problème est en fait la raison d’être de cet article.

Je vais d’abord exposer la méthode qui m’est naturellement venue à l’esprit :

Dans drag, on annule les évènements drop pour l’élément choisi. Puis dans drop, on remet les évènements pour tout le monde.

function drag(ev) {
    element = ev.target;
    ev.dataTransfer.setData('text', element.getAttribute('id'));
    // annule drop pour la cible et ses enfants
    element = ev.target;
    elements = element.querySelectorAll('li');
    element.removeEventListener('drop', drop);
    element.removeEventListener('dragover', dragover);
    for (i = 0 ; i < elements.length ; i++) {
        elements[i].removeEventListener('drop', drop);
        elements[i].removeEventListener('dragover', dragover);
    }
}
function drop(ev) {
    ev.preventDefault();
    data = ev.dataTransfer.getData('text');
    element = document.createElement('ul');
    element2 = document.getElementById(data);
    // remet drop pour la cible et ses enfants
    elements = element2.querySelectorAll('li');
    element2.addEventListener('drop', drop);
    element2.addEventListener('dragover', dragover);
    for (i = 0 ; i < elements.length ; i++) {
        elements[i].addEventListener('drop', drop);
        elements[i].addEventListener('dragover', dragover);
    }
    parent = element2.parentElement;
    element.appendChild(element2);
    ev.target.appendChild(element);
}

Ca marche quand on se trompe pas, mais des fois on drop là ou ça ne lance pas drop.

Il faut donc tout faire dans drag. Dès qu’on drag (ouais, je vais me passer de faire une blague ici), on reset les drop (on met les drop à tout le monde) et on empêche l’élément et ses descendants de drop.

function drag(ev) {
    element = ev.target;
    ev.dataTransfer.setData('text', element.getAttribute('id'));
    // remet drop pour tout le monde
    elements = document.querySelectorAll('li');
    for (i = 0 ; i < elements.length ; i++) {
        elements[i].addEventListener('drop', drop);
        elements[i].addEventListener('dragover', dragover);
    }
    // annule drop pour la cible et ses enfants
    element = ev.target;
    elements = element.querySelectorAll('li');
    element.removeEventListener('drop', drop);
    element.removeEventListener('dragover', dragover);
    for (i = 0 ; i < elements.length ; i++) {
        elements[i].removeEventListener('drop', drop);
        elements[i].removeEventListener('dragover', dragover);
    }
}
function drop(ev) {
    ev.preventDefault();
    data = ev.dataTransfer.getData('text');
    element = document.createElement('ul');
    element2 = document.getElementById(data);
    parent = element2.parentElement;
    element.appendChild(element2);
    ev.target.appendChild(element);
}

C’est déjà mieux, mais quand on joue trop longtemps, il y a des fois où on peut faire un drag and drop sur l’élément ou son descendant, et ça fait planter.

J’ai essayé de me dépatouiller avec les events, mais en vain. J’ai donc décidé de mettre un drapeau sur les éléments qui ne doivent pas accepter le drop.

function drag(ev) {
    element = ev.target;
    ev.dataTransfer.setData('text', element.getAttribute('id'));
    // remet drop pour tout le monde
    elements = document.querySelectorAll('li');
    for (i = 0 ; i < elements.length ; i++) {
        elements[i].removeAttribute('data-notarget');
    }
    // annule drop pour la cible et ses enfants
    element = ev.target;
    elements = element.querySelectorAll('li');
    element.setAttribute('data-notarget', 'true');
    for (i = 0 ; i < elements.length ; i++) {
        elements[i].setAttribute('data-notarget', 'true');
    }
}
function drop(ev) {
    isTarget = ev.target.getAttribute('data-notarget');
    if (isTarget == null || isTarget == '') {
        ev.preventDefault();
        data = ev.dataTransfer.getData('text');
        element = document.createElement('ul');
        element2 = document.getElementById(data);
        parent = element2.parentElement;
        element.appendChild(element2);
        ev.target.appendChild(element);
    }
}
function dragover(ev) {
    isTarget = ev.target.getAttribute('data-notarget');
    if (isTarget == null || isTarget == '') {
        ev.preventDefault();
    }
}

On a une solution plus robuste, mais il y a quand même quelque plantages.

Après une multitude de tests, j’ai réussit à trouver la cause : l’élément <ul> est aussi droppable et execute drop quand on vise bien dessus (sur la puce par exemple).

Je considère que c’est un bug et pas une feature, car si j’aurais voulu que <ul> soit droppable, je lui aurais affecté manuellement cet évènement.

Donc, il faut inhiber le drop sur <ul>

function drop(ev) {
    isTarget = ev.target.getAttribute('data-notarget');
    if (isTarget == null || isTarget == '') {
        ev.preventDefault();
        data = ev.dataTransfer.getData('text');
        element = document.createElement('ul');
        element.setAttribute('data-notarget', 'true');
        element2 = document.getElementById(data);
        parent = element2.parentElement;
        element.appendChild(element2);
        ev.target.appendChild(element);
    }
}
function init() {
    // exclusion de ul
    elements = document.querySelectorAll('ul');
    for (i = 0 ; i < elements.length ; i++) {
        elements[i].setAttribute('data-notarget', 'true');
    }
    // attache drop
    elements = document.querySelectorAll('li');
    for (i = 0 ; i < elements.length ; i++) {
        elements[i].addEventListener('drop', drop);
        elements[i].addEventListener('dragover', dragover);
    }
    // attache drag
    elements = document.querySelectorAll('li[draggable]');
    for (i = 0 ; i < elements.length ; i++) {
        id = elements[i].getAttribute('id');
        if (id == null || id == '') {
            elements[i].setAttribute('id', 'draggable'+i);
        }
        elements[i].addEventListener('dragstart', drag);
    }
}

Maintenant, c’est du code robuste, qui ne plante plus. Mais si je change <ul> par <ol> de partout, j’ai que des 1 qui se suivent. Pourquoi ?

C’est parcequ’on ne se pose pas la question de savoir si il existe un <ul> (ou <ol> quand on a fait le changement). On fabrique <ul>, on remplit, puis on ajoute au dom. On ne vérifie pas non plus si le <ul> qu’on laisse derrière est vide. Il faut donc nettoyer tout ça.

Nettoyage du dom et amélioration de la hiérarchie

En plus du nettoyage, j’ai un petit souci avec la hierarchie. Les éléments ne peuvent pas passer en premier ordre. La solution que j’ai trouvé, c’est de faire un <ul> qui permettra de déposer n’importe quel élément, mais qui n’est pas draggable.

Pour le nettoyage du dom, tout est dit un peu plus haut.

<ol>
    <li>D&eacute;poser ici les &eacute;l&eacute;ments de plus haut niveau
        <ol>
            <li draggable="true">Element 1</li>
            <li draggable="true">Element 2</li>
            <li draggable="true">Element 3
                <ol>
                    <li draggable="true">Element 4</li>
                </ol>
            </li>
        </ol>
    </li>
</ol>
function drop(ev) {
    isTarget = ev.target.getAttribute('data-notarget');
    if (isTarget == null || isTarget == '') {
        ev.preventDefault();
        data = ev.dataTransfer.getData('text');
        elements = ev.target.getElementsByTagName('ol');
        element = null;
        if (elements.length != 0) {
            element = elements[0];
        } else {
            element = document.createElement('ol');
            element.setAttribute('data-notarget', 'true');
        }
        element2 = document.getElementById(data);
        parent = element2.parentElement;
        element.appendChild(element2);
        ev.target.appendChild(element);
        if (parent.children.length == 0) {
            parent.remove();
        }
    }
}

Voilà, on a un drag and drop qui est fonctionnel et qui est propre.

Peaufinage

Comment on peut faire un effet d’inversion vidéo sur les éléments droppables ?

C’est facile, il suffit de savoir qu’il y a un évènement supplémentaire qui est dragleave (et pas dragout ça aurait été trop simple).

Je ne vais pas donner d’exemple qui fonctionne de partout car il faut gérer les bug de firefox sur dragleave qui donne pas le <li> mais le texte dedans alors que dragover marche bien !

<!DOCTYPE html>
<html>
 <head>
 <title>Drag and drop</title>
 </head>
 <body>
 <ol>
 <li>D&eacute;poser ici les &eacute;l&eacute;ments de plus haut niveau
 <ol>
 <li draggable="true">Element 1</li>
 <li draggable="true">Element 2</li>
 <li draggable="true">Element 3
 <ol>
 <li draggable="true">Element 4</li>
 </ol>
 </li>
 </ol>
 </li>
 </ol>
 <script>
 function drag(ev) {
 element = ev.target;
 ev.dataTransfer.setData('text', element.getAttribute('id'));
 // remet drop pour tout le monde
 elements = document.querySelectorAll('li');
 for (i = 0 ; i < elements.length ; i++) {
 elements[i].removeAttribute('data-notarget');
 }
 // annule drop pour la cible et ses enfants
 element = ev.target;
 elements = element.querySelectorAll('li');
 element.setAttribute('data-notarget', 'true');
 for (i = 0 ; i < elements.length ; i++) {
 elements[i].setAttribute('data-notarget', 'true');
 }
 ev.stopPropagation();
 }
 function drop(ev) {
 target = ev.target;
 isTarget = target.getAttribute('data-notarget');
 if (isTarget == null || isTarget == '') {
 ev.preventDefault();
 ev.target.style.backgroundColor = '#fff';
 data = ev.dataTransfer.getData('text');
 elements = ev.target.getElementsByTagName('ol');
 element.setAttribute('data-notarget', 'true');
 element = null;
 if (elements.length != 0) {
 element = elements[0];
 } else {
 element = document.createElement('ol');
 }
 element2 = document.getElementById(data);
 parent = element2.parentElement;
 element.appendChild(element2);
 ev.target.appendChild(element);
 if (parent.children.length == 0) {
 parent.remove();
 }
 }
 ev.stopPropagation();
 }
 function dragover(ev) {
 target = ev.target;
 isTarget = target.getAttribute('data-notarget');
 if (isTarget == null || isTarget == '') {
 ev.preventDefault();
 ev.target.style.backgroundColor = '#fcc';
 }
 ev.stopPropagation();
 }
 function dragleave(ev) {
 target = ev.target;
 isTarget = target.getAttribute('data-notarget');
 if (isTarget == null || isTarget == '') {
 ev.preventDefault();
 ev.target.style.backgroundColor = '#fff';
 }
 ev.stopPropagation();
 }
 function init() {
 // exclusion de ol
 elements = document.querySelectorAll('ol');
 for (i = 0 ; i < elements.length ; i++) {
 elements[i].setAttribute('data-notarget', 'true');
 }
 // drop related events
 elements = document.querySelectorAll('li');
 for (i = 0 ; i < elements.length ; i++) {
 elements[i].addEventListener('drop', drop);
 elements[i].addEventListener('dragover', dragover);
 elements[i].addEventListener('dragleave', dragleave);
 }
 // drag related events
 elements = document.querySelectorAll('li[draggable]');
 for (i = 0 ; i < elements.length ; i++) {
 id = elements[i].getAttribute('id');
 if (id == null || id == '') {
 elements[i].setAttribute('id', 'draggable'+i);
 }
 elements[i].addEventListener('dragstart', drag);
 }
 }
 document.addEventListener('DOMContentLoaded', init);
 </script>
 </body>
</html>

Ce drag and drop marche bien sur chrome, mais pas sur firefox 45.1.1 a cause de dragleave. Vu que c’est clairement un bug, je ne vais pas investiguer.