Techno-magis

Trier une liste d’éléments avec le drag'n drop de HTML5

Samedi 15 Août 2015

Comme j'ai passé un peu de temps à comprendre comment fonctionne le drag'n drop (ou glisser-déposer en français), et que j'ai perdu pas mal de temps parce que ça ne fonctionne pas du tout sur les flexboxes... En fait, j'ai bien failli abandonné jusqu'à ce que je me rende compte du souci. Bref, je suis donc parti sur une liste d’éléments en inline-block que j'ai voulu trier moi-même avec l'API native de HTML5, donc sans se servir d'un framework bien lourd comme Jquery. Je ne dis pas que Jquery c'est mal, mais on peut s'en passer, mais forcement, on perd toutes ses facilitées. De plus, j'aime parfois comprendre comment ça fonctionne dernière :


Attention

1. Ce code ne fonctionne que sur les navigateurs modernes. Sur des versions antérieures à Internet Explorer 10 ou sur de vieilles versions de Webkit ça peut ne pas fonctionner.

2. Le code présenté ici n'est pas vraiment structuré et excessivement commenté (même dans le code source des exemples). Il permet juste d'expliquer le fonctionnement à plat. Il est bien évident qu'une utilisation en condition réelle demandera de l'« encapsuler » pour s'assurer d'éviter tout confit avec le reste de l’enivrement.

Étape 1 : Mise en place

Pour ma part, j'ai un « micro-framework » (que je dois réécrire) en JavaScript qui permet de faire des raccourcis (parce qu'écrire partout document.getElementById(), c'est chiant), donc voilà mes raccourcis :

CODE :

function $id(id) {
  return document.getElementById(id);
}
function $event(element, nom, func) {
  element.addEventListener(nom, func, false);
}

Tout d'abord, il faut une liste :

CODE :

<div id="ordonable">
  <div><div>Bloc 1</div></div>
  <div><div>Bloc 2</div></div>
  <div><div>Bloc 3</div></div>
  <div><div>Bloc 4</div></div>
</div>

Que l'on va mettre sur la forme d'une liste de carrés :

CODE :

#ordonable > div {
  display:inline-block;
  width:100px;
  height:100px;
  border:2px solid #000;
  margin:2px;
  border-radius:5px;
  background-color:#CCC;
  text-align:center;
}

Voir ce que cela donne. Des boîtes carrées, les unes à la suite de l'autre. Rien d'incroyable.

Étape 2 : Une liste d’éléments draggable avec des évènements

Sur lequel je vais attacher :

CODE :

var nodes = $id('ordonable').childNodes;
for (var i = nodes.length -1; i > -1; i--) {
  var bloc = nodes[i];
  if (bloc.nodeName === "#text") {
    // suppression des nodes vides
    bloc.parentNode.removeChild(bloc);
  }
  else { 
    // c'est ici que l'on va rendre draggable
    draggable(bloc);
  }
}

Voilà tous les évènements qui vont être nécessaires pour ordonner une liste. Oui, j'aime quand mon code est aligné :

CODE :

function draggable(bloc) {
  // l’élément doit être draggable
  bloc.draggable = true;
  // au démarrage d'un glissement
  $event(bloc, 'dragstart', function (event) { console.log('dragstart', event) });
  // à l'entrée dans un élément draggable
  $event(bloc, 'dragenter', function (event) { console.log('dragenter', event) }); 
  // au survole d'un élément draggable
  $event(bloc, 'dragover',  function (event) { console.log('dragover',  event) });
  // au moment de quitter un élément draggable
  $event(bloc, 'dragleave', function (event) { console.log('dragleave', event) });
  // au relâché du glissement
  $event(bloc, 'dragend',   function (event) { console.log('dragend',   event) });
  // au déposé
  $event(bloc, 'drop',      function (event) { console.log('drop',      event) });
}

En l’occurrence, les console.log() ne sont là que pour meubler et montrer dans la console par où cela passe.

Et pour finir, il faudra une variable pour stocker l'élément qu'il faudra déplacer.

CODE :

var cible;

Voir ce que cela donne. Des boîtes carrées que l'on ne peut pas déplacer. Par contre, un évènement répond (dans Firefox) :

CODE :

dragstart dragstart { target: <div>, buttons: 1, clientX: 63, clientY: 71, layerX: 63, layerY: 71 }

Ce qui me fait dire que la chaine du nom de l'évènement fait un peu doublon, mais on s'en fout, ça finira par sauté.

Étape 3 : Au début de l'action

C'est maintenant que les choses « sérieuses » commencent. En fait, une fois que l'on a bien compris la démarche, c'est assez simple. On commence par sauvegarder l’élément que l'on vient de prendre, car à aucun moment il nous sera rappelé de quel nœud du DOM on a démarré.

CODE :

$event(bloc, 'dragstart', function (event) { 
  console.log(event)
  // on sauvegarde l'élément que l'on vient de prendre
  cible = event.target;
  // on annonce que les données sont du texte (text/plain ou text/html)
  event.dataTransfer.setData('text/plain', null);
  // on peut attacher une classe pour faire une animation
  cible.classList.add('dragstart');
});

On va un peu changer le style pour effectuer une petite animation qui va rendre translucide l'élément que l'on va sélectionner grâce à la classe « dragstart » :

CODE :

#ordonable > div {
  display:inline-block;
  width:100px;
  height:100px;
  border:2px solid #000;
  margin:2px;
  border-radius:5px;
  background-color:#CCC;
  text-align:center;
  transition: opacity 0.5s linear 0s;
  opacity:1;
}
#ordonable > div.dragstart {
  opacity:.2;
}

Ça ne peut fonctionner correctement que si l'on retire la classe quand l'on arrête l'action. Donc à la fin du drag on retire la classe de l’élément. Une des premières raisons de la sauvegarde, mais ce n'est pas la seule.

CODE :

$event(bloc, 'dragend',   function (event) {
  console.log(event)
  // on supprime la classe quand l'action est finie
  cible.classList.remove('dragstart');
});

Voir ce que cela donne. Cependant, c'est joli, ça donnerait presque l'impression que ça fonctionne très bien. Hélas, si par malheur on prend un enfant de notre élément draggable, il sera considéré comme lui aussi draggable. Malheureusement, ce n'est pas le parent qui sera notre élément cible, mais juste l'enfant, ce qui ne pourra pas fonctionne avec, par exemple, un bout de texte.

On va rendre cela plus robuste en cherchant vraiment l’élément que l'on à marqué comme draggable. Une fonction à ajouter aux raccourcis :

CODE :

function $parent(e, classe) {
  return e.draggable ? e : $parent(e.parentNode, classe);
}

Elle va remonter l'arbre jusqu'à ce qu'elle trouver l’élément draggable.

Vous remarquerez aussi que si l'on prend un enfant de l'élément, le visuel sera différent. Il est possible de le changer et s'assurer de toujours donner l'impression que bien prendre l'élément voulut. Pour cela, on va un peu changer le visuel ce que l'on prend au démarrage avec setDragImage() :

CODE :

$event(bloc, 'dragstart', function (event) { 
  console.log(event, event.target)
  // on sauvegarde l'élément que l'on vient de prendre (en prenant soin de prendre le carré complet)
  cible = $parent(event.target);
  // prend pour visuel la cible que l'on va mettre, par rapport au curseur, 
  // à une position 50 à gauche, 50 vers le haut ce qui va le centrer sur la main
  event.dataTransfer.setDragImage(cible, 50, 50);
  // on annonce que les données sont du texte (text/plain ou text/html)
  event.dataTransfer.setData('text/plain', null);
  // on peut attacher une classe pour faire une animation
  cible.classList.add('dragstart');
});

Voir ce que cela donne. On a maintenant des carrés que l'on peut prendre avec une petite animation. Cependant, pour l'instant ça ne fait que prendre et relâcher sans rien changer à la liste. On peut constater dans la console que les évènements dragenter, dragover et dragleave répondent, c'est maintenant vers eux que l'on va poursuivre.

Étape 4 : Le survole

On va maintenant commencer l’interaction avec le reste de la liste. On commence par le survole, où l'on va ajouter quelque chose pour montrer quel est l'élément draggable que l'on survole :

CODE :

$event(bloc, 'dragover',  function (event) { 
  console.log(event) 
  var el = $parent(event.target);
  // au survole d'un élément draggable, s'il n'a pas la classe dragover on lui ajouter.
  if (el && !el.classList.contains('dragover')) {
    el.classList.add('dragover');
  }    
});

Pour la classe en question, ici, on a juste rendu le carré plus sombre et ajouté à la fin du CSS :

CODE :

#ordonable > div.dragover {
  background-color:#888;
}

Le petit problème c'est que si l'on fait cela, ça fonce les carrés, sauf que si on les survole toutes ils sont tous sombres. Ça ne sert un peu à rien. On va donc supprimer notre classe dragover en quittant l’élément.

Complément

Pourquoi la classe « dragover » est-elle mise au dragover et non au dragenter ? En fait, si l'on se déplace trop vite on se retrouver directement en « over » sans passer par l’« enter ». La partie « enter » est ridicule (dans Firefox), et il est plus simple de l'ajouter au survole, car il faudra le faire si l'on ne passe par le dragenter. Donc autant faire notre action sur le dragover qui est appelé immédiatement après le dragenter.

On va maintenant virer la classe « dragover » quand on quitte l'élément, et pour ne pas s'embêter, on va réinitialiser toute la liste. Ça évitera des bizarres en cas de lag qui aurait fait sauter l'évènement dragleave. On va ajouter une méthode de réinitialisation de la liste en partant de l'élément draggable que l'on récupère avec l'évènement.

CODE :

function reinit(element) {
  // on s'assure de prendre l’élément draggable
  var el = $parent(element);
  if (el) {
    // on parcourt tous les nœuds parents à l’élément pour supprimer ce que l'on a ajouté au survole
    // on s'assure de se fait qu'il n'y a aucune anomalie
    [].forEach.call(el.parentNode.children, function (node) {
      node.classList.remove('dragover');
    });
  }
  // on supprime la cible
  cible != null;
}

Et pour être encore plus sûr, on va réinitialiser à l'entrée et à la sortie de l'élément draggable comme ceci au cas où l'action dragleave ou dragenter ferait quelques caprices.

CODE :

$event(bloc, 'dragenter', function (event) { 
  console.log(event)
  reinit(event.target);
});
$event(bloc, 'dragleave', function (event) { 
  console.log(event)
  reinit(event.target);
});

Voir ce que cela donne. On a maintenant l'impression que l'élément qui est survolé par notre carré tenu à la souris réagit ensemble. C'est beau. (rires)

Étape 5 : À droite ou à gauche ?

C'est bien beau, mais c'est encore loin d'être fini. Maintenant, il faut choisir où est ce que l'on va déposer l’élément et comment ça détermine ?

L'évènement nous permet de connaître trois choses :

  • la position de la souris dans la page event.clientX
  • la position relative de l'élément en x et y : element.offsetLeft et element.offsetTop
  • la taille de notre élément : element.clientWidth

Le premier point est sympa, le second est peu plus embêtant, car il ne correspond pas avec le premier. Pour le dernier, ça nous servira à trouver de quel côté nous nous trouvons.

Bref, il est difficile de faire un calcul de position entre deux valeurs dans deux référentiels différents. On va donc faire simple, faire en sorte que le point qui ne nous va pas prenne le même référentiel. Pour ce faire, on va calculer récursivement jusqu'à parent le plus bas du DOM la position de notre élément en additionnant les positions relatives des éléments de tous les parents de notre élément.

CODE :

function getOffsetAbsolute ( element ) {
  var x = 0, y = 0;
  // tant qu'il y a un parent on additionne
  do {
    x += element.offsetLeft || 0;
    y += element.offsetTop  || 0;
    // on prend l’élément du référentiel parent (déterminé par la propriété position:relative)
    element = element.offsetParent;
  } while(element);
  // les positions absolues
  return { top: y, left: x };
}

Explication

Dans notre exemple, ça ne sert à rien, le référentiel sera le bon. Mais pour le fun, je fais faire en sorte que notre liste soit relative. Il y a donc une différence avec la marge du document qui donne cela :
1. { offsetLeft: 2, offsetTop: 2 } ← les marges de 2px de notre élément par rapport à #ordonable
2. { offsetLeft: 8, offsetTop: 8 } ← les marges de 8px de #ordonable par rapport à body
3. { offsetLeft: 0, offsetTop: 0 } ← les marges de 0px de body par rapport à html

Si je retire l'état relatif à #ordonable, ça serait bon, car le référentiel parent est quasiment le plus bas :
1. { offsetLeft: 10, offsetTop: 10 } ← les marges de 2px de notre élément + 8px de body par rapport à body
2. { offsetLeft: 0, offsetTop: 0 } ← les marges de 0px de body par rapport à html

Après cet interlude sur « comment ça fonctionne les référentiels », on va passer à la mise en application. Pour faire le calcul, c'est assez simple. On veut savoir si : position de la souris - position de l’élément < taille de l’élément 2 est vrai ou faux. Avec cette formule, on divise en deux l'élément ce qui nous permet de savoir si l'on est dans la partie à gauche ou à droite.

On va jouter ce qu'il nous faut pour déterminer cela à notre évènement dragover :

CODE :

$event(bloc, 'dragover',  function (event) { 
  console.log(event) 
  var el = $parent(event.target);
  if (el) {
    if (!el.classList.contains('dragover')) {
      el.classList.add('dragover');
    }
    // on cherche si on est dans la partie de gauche (avant)
    var etat = event.clientX - getOffsetAbsolute(el).left < el.clientWidth / 2 ;
    // si oui, on ajout avant et retire apres, sinon l'inverse
    el.classList.add   (etat ? 'avant' : 'apres');
    el.classList.remove(etat ? 'apres' : 'avant');
  }
});

Pour que ça soit sympa visuellement, je vais faire en sorte d'une barre apparaisse à avant ou après mon carré survolé. Et tant qu'à faire que la barre soit à la même position entre deux éléments l'un à côté de l'autre (sur la même ligne) que l'on soit à après l'un ou avant le suivant. Je n'ai pas fait cette article pour expliquer le CSS donc voici le code utilisé :

CODE :

#ordonable > div {
  /* ... */
  position:relative;
}
#ordonable .dragover.apres::after,
#ordonable .dragover.avant::before{ 
  border-left:2px solid red;
  position:absolute;
  top:-2px;
  right:-5px;
  content:" ";
  height:calc(100% + 4px);
}
#ordonable .dragover.avant::before {
  right:auto;
  left:-5px;
}

On se rend compte que si l'on ne supprime pas aussi les classes « avant » ou « apres » quand on quitte un élément, ça peut donner n'importe quoi. On va donc aussi les supprimer dans la réinitialisation :

CODE :

node.classList.remove('dragover', 'avant', 'apres');

Voir ce que cela donne. C'était probablement l'étape la plus compliquée. Car elle demande une bonne connaissance du fonctionnement du DOM.

Étape 6 : On dépose

Nous voilà sur l'étape finale. On sait quel élément on veut déplacer et où dans notre liste. Maintenant, il ne reste plus qu'à le déplacer avec notre dernière action drop.

Pour commençait il faut que puisse accéder au drop en ajoutant à la fin de dragover :

CODE :

$event(bloc, 'dragover',  function (event) { 
  /* ... */
  // pour permettre la propagation vers drop
  event.preventDefault();
});

Dans les faits, il ne se passe rien sous Chrome et ça merde sous Firefox qui me lance l'URL « null ». Pour éviter tout problème, on va faire en sorte que notre action s’arrête au drop avec, la même chose :

CODE :

$event(bloc, 'drop',      function (event) {
  console.log(event) 
  // pour stopper la propagation (dans Firefox cela renvoie vers l'url "null")
  event.preventDefault();
});

Maintenant, on va prendre notre « cible » et la déplacer. C'est assez simple :

CODE :

function placer (el, pos) {
  var parent = el.parentNode;
  // si après
  if (pos === "apres") {
    // et si qu'il y a un élément après on place la cible avant le suivant
    if (el.nextSibling) {
      parent.insertBefore(cible, el.nextSibling);
    }
    // sinon on la place à la fin de la liste
    else {
      parent.appendChild(cible);
    }
  }
  // si avant : on place juste la cible avant l’élément avant
  else {
    parent.insertBefore(cible, el);
  }
}

Une fois la méthode de déplacement d’élément en place, il nous reste plus qu'à l’appeler.

CODE :

$event(bloc, 'drop',      function (event) {
  console.log(event) 
  // pour stop la propagation (dans Firefox cela renvoie vers l'url "null")
  event.preventDefault();
  // on prend l'élement suvoler et on demande à déplacer notre cible vers lui
  var el = $parent(event.target);
  if ( el && cible != null) {
    placer(el, el.classList.contains('apres') ? 'apres' : 'avant');
  }
});

Voir ce que cela donne. Une liste que l'on peut ordonner comme on l'entend en faisant glisser les éléments. À partir de là, on peut aller plus loin et faire de choses plus sophistiquer. Il y a des choses que l'on peut faire en plus pour rendre le tout plus robuste.

Conclusion

Bon, pour être franc, j'ai l'impression que le drag'n drop fonctionne un peu mieux dans Chrome, car dans Firefox j'ai du ajouter un paquet de trucs pour éviter les débordements et les évènements perdus ce qui est un peu chiant. Étant exclusivement sous Linux (et ayant pété ma VM Windows et ayant la flemme de la réinstaller) je ne peux pas tester sous Windows.

De plus, pour plus de sécurité on peut s'assurer que l'élément ne vient pas de l'extérieur dans Firefox :

On peut tester qu'il ne s'agit bien des données transférées et non des de l'extérieur (du système) en dragover:

CODE :

event.dataTransfer.dropEffect === "move" /*Fx*/ || event.dataTransfer.dropEffect === "none" /*Chrome*/ 

De plus, le fait que l'élément de la liste soit en relatif casse certains évènements... Bref, pour bien faire, il faudrait que je reporte quelques bugs.

Complément (17/08)

J'ai testé un peu au boulot avec Windows 7 avec et sans Areo. À ma grande surprise, ça passe sans problème sous IE 10 (j'étais médisant). Par contre, supprimer Areo pour être en mode « Classique » à un effet néfaste, quel que soit le navigateur setDragImage() ne fonctionne pas du tout.

Catégories :
Par Zéfling, le 15/08/2015 à 22:05:46
Le billet a été lue 515 fois, avec 0 commentaire publié.

Aucun commentaire

Écrivez le votre ci-dessous.

Écrire un commentaire