15 dicembre 2021

Drag'n'Drop con gli eventi del mouse

Il Drag’n’Drop, in termini di interfaccia utente, è una soluzione grandiosa. Il fatto di poter prendere qualcosa, trascinarla e rilasciarla è un modo semplice ed intuitivo per fare tantissime operazioni, dal copiare e spostare documenti (come nei gestori di files), al fare un ordine online (rilasciando un prodotto in un carrello).

Nello standard HTML attuale c’è una sezione sul Drag and Drop con eventi speciali come dragstart, dragend, e via dicendo.

Questi eventi ci permettono di supportare tipi particolari di drag’n’drop, come gestire il trascinamento di un file dal sistema operativo ed il rilascio dentro la finestra del browser, in modo che JavaScript possa accedere al contenuto di tali files.

Ma i Drag Events hanno anche delle limitazioni. Ad esempio, non possiamo prevenire il trascinamento da una certa sezione. Inoltre non possiamo rendere il trascinamento solo “orizzontale” o “verticale”. E ci sono tante altre azioni drag’n’drop che non è possibile sfruttare. Inoltre il supporto dei dispositivi mobile per questo tipo di eventi è abbastanza scarso.

Di conseguenza, vedremo come implementare il Drag’n’Drop solo tramite l’utilizzo degli eventi del mouse.

Algoritmo del Drag’n’Drop

L’algoritmo di base del Drag’n’Drop è qualcosa del genere:

  1. Al mousedown – prepara l’elemento per lo spostamento, se necessario (magari crea un suo clone, o aggiungi una classe o altro).
  2. Quindi al mousemove spostalo variando left/top con il position:absolute.
  3. Al mouseup – esegui tutte le azioni coinvolte per completare il drag’n’drop.

Queste sono le basi. Dopo vedremo come gestire altre caratteristiche, come evidenziare gli altri elementi sottostanti mentre effettuiamo il trascinamento su di essi.

Ecco un’implementazione del trascinamento di un pallone:

ball.onmousedown = function(event) {
  // (1) preparazione dello spostamento: imposta il posizionamento assoluto e lo z-index al massimo valore utile
  ball.style.position = 'absolute';
  ball.style.zIndex = 1000;

  // spostamento all'esterno da ogni elemento genitore e direttamente verso il body della pagina
  // per posizionarlo relativamente ad esso
  document.body.append(ball);

  // centratura del pallone alle coordinate (pageX, pageY)
  function moveAt(pageX, pageY) {
    ball.style.left = pageX - ball.offsetWidth / 2 + 'px';
    ball.style.top = pageY - ball.offsetHeight / 2 + 'px';
  }

  // spostamento del nostro pallone con posizionamento assoluto sotto il puntatore
  moveAt(event.pageX, event.pageY);

  function onMouseMove(event) {
    moveAt(event.pageX, event.pageY);
  }

  // (2) spostamento del pallone al mousemove
  document.addEventListener('mousemove', onMouseMove);

  // (3) rilascio del pallone, rimozione dei gestori non necessari
  ball.onmouseup = function() {
    document.removeEventListener('mousemove', onMouseMove);
    ball.onmouseup = null;
  };

};

Eseguendo il codice, noteremo qualcosa di anomalo. All’inizio del drag’n’drop, il pallone si “duplica”: abbiamo cominciato trascinando il suo “clone”.

Ecco un esempio in azione:

Prova a fare il drag’n’drop con il mouse per vedere questo comportamento anomalo.

Questo accade perché il browser ha una sua implementazione del drag’n’drop per immagini e qualche altro elemento. La esegue in automatico e va quindi in conflitto con la nostra.

Per disabilitarlo:

ball.ondragstart = function() {
  return false;
};

Ora è tutto a posto.

In azione:

Un altro aspetto importante – noi teniamo traccia di mousemove nel document, non su ball. A prima vista potrebbe sembrare che il mouse sia sempre sopra il pallone, e possiamo attivare mousemove su di essa.

Ma come noto, mousemove viene generato spesso, ma non per ogni pixel. Quindi a seguito di qualche movimento rapido potrebbe saltare dal pallone a qualche punto nel bel mezzo del documento (o anche fuori dalla finestra).

Di conseguenza dovremmo metterci in ascolto sul document per la cattura.

Posizionamento corretto

Negli esempi precedenti il pallone viene sempre spostato in modo che il suo centro sia sotto il puntatore:

ball.style.left = pageX - ball.offsetWidth / 2 + 'px';
ball.style.top = pageY - ball.offsetHeight / 2 + 'px';

Non male, ma c’è un effetto collaterale. Per innescare il drag’n’drop, potremmo cominciare mousedown da un punto qualunque del pallone. Ma se lo “prendessimo” dai bordi, si centrerebbe repentinamente sotto il puntatore facendo una specie di “salto”.

Sarebbe meglio se mantenessimo lo spostamento iniziale dell’elemento rispetto al puntatore.

Ad esempio, se cominciassimo dal bordo del pallone, il puntatore dovrebbe rimanere sul bordo mentre lo trasciniamo.

Aggiorniamo il nostro algoritmo:

  1. Quando un utente preme il pulsante (mousedown) – memorizza la distanza del puntatore dall’angolo in alto a sinistra del pallone nelle variabili shiftX/shiftY. Manterremo questa distanza durante il trascinamento.

    Per ottenere questi scostamenti possiamo sottrarre le coordinate:

    // onmousedown
    let shiftX = event.clientX - ball.getBoundingClientRect().left;
    let shiftY = event.clientY - ball.getBoundingClientRect().top;
  2. Quindi, durante il trascinamento posizioneremo il pallone con lo stesso scostamento relativo al puntatore, in questo modo:

    // onmousemove
    // il pallone ha position:absolute
    ball.style.left = event.pageX - shiftX + 'px';
    ball.style.top = event.pageY - shiftY + 'px';

Il codice definitivo con un posizionamento ottimale:

ball.onmousedown = function(event) {

  let shiftX = event.clientX - ball.getBoundingClientRect().left;
  let shiftY = event.clientY - ball.getBoundingClientRect().top;

  ball.style.position = 'absolute';
  ball.style.zIndex = 1000;
  document.body.append(ball);

  moveAt(event.pageX, event.pageY);

  // sposta il pallone alle coordinate (pageX, pageY)
  // tenendo conto dello scostamento iniziale
  function moveAt(pageX, pageY) {
    ball.style.left = pageX - shiftX + 'px';
    ball.style.top = pageY - shiftY + 'px';
  }

  function onMouseMove(event) {
    moveAt(event.pageX, event.pageY);
  }

  // muovi il pallone al mousemove
  document.addEventListener('mousemove', onMouseMove);

  // rilascia il pallone, rimuovi i gestori non necessari
  ball.onmouseup = function() {
    document.removeEventListener('mousemove', onMouseMove);
    ball.onmouseup = null;
  };

};

ball.ondragstart = function() {
  return false;
};

In azione (inside <iframe>):

La differenza è particolarmente visibile se trasciniamo il pallone dall’angolo in basso a destra. Nell’esempio precedente, il pallone “salterebbe” sotto il puntatore. Adesso segue fluidamente il puntatore dalla posizione corrente.

Potenziali obiettivi per il drop (droppables)

Negli esempi precedenti il pallone può essere rilasciato “ovunque”. In applicazioni concrete generalmente prendiamo un oggetto e lo lasciamo su un altro. Ad esempio, un “file” dentro una “cartella” o cose del genere.

Parlando in maniera astratta, prendiamo un elemento “draggable” e lo rilasciamo su uno “droppable”.

Dobbiamo quindi sapere:

  • dove viene rilasciato l’elemento alla fine del Drag’n’Drop – per eseguire l’azione corrispondente,
  • e, preferibilmente, conoscere il droppable sul quale lo stiamo rilasciando, per evidenziarlo.

La soluzione è piuttosto interessante e leggermente complicata, e la affronteremo qui.

Quale potrebbe essere l’idea iniziale? Probabilmente quella di impostare dei gestori mouseover/mouseup sui potenziali droppables?

Non funzionerebbe.

Il problema principale, sarebbe che durante il trascinamento, l’elemento draggable è sempre sopra gli altri. E gli eventi del mouse vengono generati sull’elemento superiore, e non su quelli sotto.

Per esempio, sotto ci sono due elementi <div>, uno rosso sopra uno blu (lo copre del tutto). Non c’è modo di catturare un evento su quello blu, perché il rosso sta sopra:

<style>
  div {
    width: 50px;
    height: 50px;
    position: absolute;
    top: 0;
  }
</style>
<div style="background:blue" onmouseover="alert('non funziona mai')"></div>
<div style="background:red" onmouseover="alert('sul rosso!')"></div>

Stessa cosa per un elemento draggable. Il pallone sta sempre sopra gli altri elementi, e quindi gli eventi vengono generati su di esso. Qualunque gestore assegnassimo agli elementi sotto, non funzionerebbero.

Questo è il motivo per cui l’idea iniziale di impostare i gestori su dei potenziali droppables non funziona. I gestori non verrebbero eseguiti.

Come fare, quindi?

C’è un metodo chiamato document.elementFromPoint(clientX, clientY) che restituisce le coordinate relative alla window, dell’elemento più annidato (o null se le coordinate restituite sono fuori dalla window).

Possiamo usarlo su qualunque nostro gestore di evento del mouse per rilevare dei potenziali droppable sotto il puntatore, in questo modo:

// dentro un gestore di evento del mouse
ball.hidden = true; // (*) nasconde l'elemento che trasciniamo

let elemBelow = document.elementFromPoint(event.clientX, event.clientY);
// elemBelow è l'elemento sotto il pallone, potrebbe essere droppable

ball.hidden = false;

Nota bene: abbiamo bisogno di nascondere il pallone prima della chiamata (*). Altrimenti otterremmo la palla a queste coordinate, dato che sarebbe questo l’elemento più in alto sotto il puntatore: elemBelow=ball. Quindi lo nascondiamo per mostrarlo nuovamente immediatamente dopo.

Possiamo usare questo codice per sapere quale elemento stiamo “sorvolando” in ogni momento. E gestire il rilascio quando accade.

Un codice esteso di onMouseMove per individuare gli elementi “droppable”:

// droppable potenziale che stiamo sorvolando in questo momento
let currentDroppable = null;

function onMouseMove(event) {
  moveAt(event.pageX, event.pageY);

  ball.hidden = true;
  let elemBelow = document.elementFromPoint(event.clientX, event.clientY);
  ball.hidden = false;

  // gli eventi mousemove possono essere generati fuori dalla window (quando il pallone è trascinato fuori dallo schermo)
  // se clientX/clientY sono fuori dalla window, elementFromPoint restituisce null
  if (!elemBelow) return;

  // i potenziali elementi droppables sono contrassegnati con la classe "droppable" (la logica potrebbe essere anche altra)
  let droppableBelow = elemBelow.closest('.droppable');

  if (currentDroppable != droppableBelow) {
    // sorvolando in entrata o in uscita...
    // nota bene: entrambi i valori potrebbero essere null
    //   currentDroppable=null se non siamo su un elemento prima di questo evento (es. su uno spazio vuoto)
    //   droppableBelow=null se non siamo attualmente droppable, durante questo evento

    if (currentDroppable) {
      // la logica per elaborare "il sorvolo in uscita" dal droppable (rimuove l'evidenziatura)
      leaveDroppable(currentDroppable);
    }
    currentDroppable = droppableBelow;
    if (currentDroppable) {
      // la logica per elaborare "il sorvolo in entrata" sul droppable
      enterDroppable(currentDroppable);
    }
  }
}

Nel seguente esempio, quando il pallone viene trascinato sopra la porta, la porta viene evidenziata.

Risultato
style.css
index.html
#gate {
  cursor: pointer;
  margin-bottom: 100px;
  width: 83px;
  height: 46px;
}

#ball {
  cursor: pointer;
  width: 40px;
  height: 40px;
}
<!doctype html>
<html>

<head>
  <meta charset="UTF-8">
  <link rel="stylesheet" href="style.css">
</head>

<body>

  <p>Drag the ball.</p>

  <img src="https://en.js.cx/clipart/soccer-gate.svg" id="gate" class="droppable">

  <img src="https://en.js.cx/clipart/ball.svg" id="ball">

  <script>
    let currentDroppable = null;

    ball.onmousedown = function(event) {

      let shiftX = event.clientX - ball.getBoundingClientRect().left;
      let shiftY = event.clientY - ball.getBoundingClientRect().top;

      ball.style.position = 'absolute';
      ball.style.zIndex = 1000;
      document.body.append(ball);

      moveAt(event.pageX, event.pageY);

      function moveAt(pageX, pageY) {
        ball.style.left = pageX - shiftX + 'px';
        ball.style.top = pageY - shiftY + 'px';
      }

      function onMouseMove(event) {
        moveAt(event.pageX, event.pageY);

        ball.hidden = true;
        let elemBelow = document.elementFromPoint(event.clientX, event.clientY);
        ball.hidden = false;

        if (!elemBelow) return;

        let droppableBelow = elemBelow.closest('.droppable');
        if (currentDroppable != droppableBelow) {
          if (currentDroppable) { // null se prima di questo evento non ci trovavamo sopra un elemento droppable
            leaveDroppable(currentDroppable);
          }
          currentDroppable = droppableBelow;
          if (currentDroppable) { // null se adesso non ci stiamo posizionando su un elemento droppable
            // (maybe just left the droppable)
            enterDroppable(currentDroppable);
          }
        }
      }

      document.addEventListener('mousemove', onMouseMove);

      ball.onmouseup = function() {
        document.removeEventListener('mousemove', onMouseMove);
        ball.onmouseup = null;
      };

    };

    function enterDroppable(elem) {
      elem.style.background = 'pink';
    }

    function leaveDroppable(elem) {
      elem.style.background = '';
    }

    ball.ondragstart = function() {
      return false;
    };
  </script>


</body>
</html>

Adesso abbiamo l’attuale “obiettivo drop”, sul quale stiamo sorvolando, dentro la variabile currentDroppable durante tutta l’operazione, e possiamo usarlo per evidenziarlo o per altre cose.

Riepilogo

Abbiamo preso in considerazione un algoritmo base del Drag’n’Drop.

I componenti chiave:

  1. Flusso degli eventi: ball.mousedowndocument.mousemoveball.mouseup (non dimenticare di eliminare il ondragstart nativo).
  2. All’inizio del trascinamento – ricorda lo scostamento iniziale del puntatore rispetto all’elemento: shiftX/shiftY e di mantenerlo durante tutta la fase di trascinamento.
  3. Rileva gli elementi droppable sotto il puntatore tramite document.elementFromPoint.

Possiamo trarre molto da queste basi.

  • Al mouseup possiamo completare intelligentemente il rilascio: modificare dati, spostare degli elementi vicini.
  • Possiamo evidenziare gli elementi che stiamo sorvolando.
  • Possiamo limitare il trascinamento in una certa area o direzione.
  • Possiamo usare la event delegation per mousedown/up. Un gestore evento per aree vaste che controlla event.target può gestire centinaia di elementi.
  • E così via.

Esistono frameworks che basano intere architetture su di esso: DragZone, Droppable, Draggable ed altre classi. La maggior parte di essi fanno cose simili a quelle appena descritte, quindi potrebbe essere semplice comprenderle adesso. Oppure potresti preparartelo da te, dal momento che abbiamo visto quanto sia facile da fare, talvolta più semplice di adattare una soluzione di terze parti.

Esercizi

importanza: 5

Create uno cursore a scorrimento:

Trascinare il cursore blu con il mouse e spostarlo.

Dettagli importanti:

  • Quando si preme il pulsante del mouse, durante il trascinamento il mouse dovrebbe poter andare sotto o sopra il cursore, senza interromperlo (comodo per l’utente).
  • Se il mouse si muove molto velocemente da sinistra a destra, il cursore si dovrebbe fermare esattamente sul bordo.

Apri una sandbox per l'esercizio.

Come possiamo notare dall’HTML/CSS, lo slider è un <div> con sfondo colorato, che contiene un altro <div> con position:relative.

Per posizionare il cursore useremo position:relative, per fornire le coordinate relative al genitore, ed in questo caso è meglio usarlo al posto di position:absolute.

Quindi andremo a implementare il Drag’n’Drop esclusivamente orizzontale con il vincolo sulla larghezza.

Apri la soluzione in una sandbox.

importanza: 5

Questa attività può essere d’aiuto per comprendere vari aspetti del Drag’n’Drop e del DOM.

Rendete tutti gli elementi trascinabili, attraverso la classe draggable, come il pallone nel capitolo appena studiato.

Requisiti:

  • Utilizzare la event delegation per tenere traccia dell’inizio del trascinamento: un solo gestore evento sul document per il mousedown.
  • Se gli elementi vengono trascinati verso i bordi in alto o in basso, la pagina deve scrollare di conseguenza.
  • Lo scroll in orizzontale non è previsto, (il che rende l’attività più semplice, aggiungerlo è facile).
  • Gli elementi trascinabili o le loro parti non devono mai uscire fuori dalla finestra, anche dopo movimenti veloci.

La demo è troppo grande per essere inclusa qui, quindi ecco il link.

Demo in una nuova finesta

Apri una sandbox per l'esercizio.

Per trascinare l’elemento possiamo usare position:fixed, il quale semplifica la gestione delle coordinate. Alla fine dovremmo tornare ad utilizzare position:absolute per posizionare l’elemento all’interno del document.

Se le coordinate sono in alto/basso rispetto alla finestra, useremo window.scrollTo per scrollare la pagina fino al punto desiderato.

Maggiori informazioni nel codice, tra i commenti.

Apri la soluzione in una sandbox.

Mappa del tutorial