Drag & drop

Raahaaminen ja pudottaminen on käyttöliittymässä houkutteleva ele: se näyttää suoralta ja luontevalta. Toteuttaminen on kuitenkin selaimessa poikkeuksellisen hankalaa, ja osalle käyttäjistä ele on kokonaan käyttökelvoton, jos rinnalle ei tarjota muuta tapaa.

Tämä sivu käy läpi HTML:n oman drag and drop -rajapinnan, sen tavallisimmat ansat, tiedostojen pudotuksen sekä sen, miten sama toiminto tarjotaan myös ilman raahaamista.

Kaksi eri ongelmaa

Ensimmäinen päätös kannattaa tehdä ennen kuin kirjoittaa riviäkään koodia: mitä ollaan oikeastaan siirtämässä. Vastaus ratkaisee, mitä rajapintaa käytetään.

Datan siirto rajan yliSiirtely oman sivun sisällä
Esimerkkitiedosto työpöydältä sivulle, teksti toisesta ikkunastalistan järjestäminen, kanban-taulun kortit
RajapintaHTML drag and droppointer-tapahtumat
Kosketusnäytötei toimi luotettavastitoimii
Vaihtoehtojaei ole — tämä on ainoa keinouseita, ja parempia

HTML:n drag and drop on peruja Internet Explorer 5:stä, ja se näkyy rajapinnan muotoilussa. Se on silti ainoa tapa vastaanottaa pudotus selaimen ulkopuolelta, ja tiedostojen pudotus on sen selvästi hyödyllisin käyttötapaus.

Sivun sisäiseen siirtelyyn se on huono valinta, koska se ei toimi kosketuksella luotettavasti. Siihen kannattaa käyttää pointer-tapahtumia.

Pienin toimiva esimerkki

Aloitetaan siitä, mikä kaataa lähes jokaisen ensimmäisen yrityksen.

<p id="lahde" draggable="true">Raahaa minua</p>
<div id="kohde">Pudota tähän</div>
const lahde = document.getElementById("lahde");
const kohde = document.getElementById("kohde");

lahde.addEventListener("dragstart", function (e) {
  e.dataTransfer.setData("text/plain", "Hei maailma");
});

kohde.addEventListener("dragover", function (e) {
  // ILMAN TÄTÄ drop-tapahtumaa ei tule koskaan.
  // Selaimen oletustoiminto on KIELTÄÄ pudotus, joten se on estettävä.
  e.preventDefault();
});

kohde.addEventListener("drop", function (e) {
  e.preventDefault();
  kohde.textContent = e.dataTransfer.getData("text/plain");
});

Oletustoiminto on kieltää pudotus. Tämä on epäintuitiivista ja se on ylivoimaisesti yleisin syy siihen, ettei oma drag and drop toimi: kaikki tapahtumankäsittelijät ovat paikallaan, mutta drop ei laukea koskaan, koska dragover-käsittelijästä puuttuu preventDefault.

Huomaa, että preventDefault tarvitaan molemmissa. dragover-käsittelijässä se sallii pudotuksen, drop-käsittelijässä se estää selaimen oman toiminnon — esimerkiksi pudotetun tiedoston avaamisen.

Kokeile esimerkkiä ja seuraa konsolia. Kommentoi sen jälkeen javascriptista dragover-käsittelijän preventDefault-kutsu pois ja aja koodi uudelleen Run-painikkeella: pudotus ei enää onnistu eikä drop-tapahtumaa tule. Erillisenä sivuna: dd1.html.

Tapahtumat

Tapahtumia on kahdeksan, ja niitä on helpompi hahmottaa sen mukaan, kumpi elementti ne saa.

Lähde-elementtiMilloin
dragstartraahaus alkaa — tässä asetetaan siirrettävä data
dragtoistuvasti raahauksen aikana
dragendraahaus päättyy — myös silloin kun se keskeytyy
Kohde-elementtiMilloin
dragenterraahattava saapuu kohteen päälle
dragovertoistuvasti kohteen päällä — preventDefault tässä
dragleaveraahattava poistuu kohteen päältä
droppudotus tapahtui — tässä data luetaan

dragend laukeaa lähde-elementissä aina — myös silloin kun käyttäjä keskeytti raahauksen tai päästi irti kohdassa, johon pudottaminen ei ollut sallittua. Siksi siivous eli korostusten poistaminen ja tilan palauttaminen kuuluu sinne eikä drop-käsittelijään, joka voi jäädä laukeamatta.

Vanhoissa esimerkeissä näkee myös tapahtuman dragexit. Se ei ole koskaan ollut osa standardia vaan Firefoxin oma lisäys, eikä sitä enää ole. Käytä dragleave-tapahtumaa.

draggable-attribuutti

Tavallinen elementti ei ole raahattava. Raahattavuus kytketään päälle draggable-attribuutilla:

<div draggable="true">Raahattava</div>

Attribuutti ei ole totuusarvoinen kuten required tai disabled, vaan sille on kirjoitettava arvo. Pelkkä draggable ilman arvoa ei kelpaa XHTML:ssä, ja draggable="false" on eri asia kuin attribuutin puuttuminen.

Linkit ja kuvat ovat raahattavia oletuksena. Tämä hämää kahteen suuntaan: oma div ei liiku ennen kuin attribuutti on lisätty, ja kuvalistassa raahaus lähtee käyntiin vaikka sitä ei haluttaisi. Jos teet kuvista omia raahattavia kortteja, kuvan oma raahaus on estettävä asettamalla sille draggable="false".

DataTransfer

Siirrettävä data kulkee DataTransfer-oliossa, joka löytyy tapahtuman dataTransfer-ominaisuudesta. Data talletetaan MIME-tyypin alle, ja samaan raahaukseen voi liittää saman sisällön useana eri tyyppinä:

lahde.addEventListener("dragstart", function (e) {
  // Sama sisältö useassa muodossa. Vastaanottaja valitsee sen,
  // jonka se osaa käsitellä.
  e.dataTransfer.setData("text/plain", "TIEA2120");
  e.dataTransfer.setData("text/html", "<strong>TIEA2120</strong>");

  // Oma tyyppi kertoo, että pudotus tulee omasta sovelluksesta
  e.dataTransfer.setData("application/x-kurssi", "TIEA2120");
});

Jos pudotus tehdään tekstinkäsittelyohjelmaan, se poimii text/html-muodon ja saa lihavoinnin mukaan. Jos pudotus tehdään tekstieditoriin, se poimii text/plain-muodon. Oma sovellus tunnistaa oman tyyppinsä ja tietää, että data tulee siltä itseltään.

MIME-tyypit

MIME-tyyppi kertoo, mitä dataa jokin sisältää — ei sitä, miltä se näyttää tai mikä tiedostopääte sillä on. Tyyppi kirjoitetaan muodossa tyyppi/alityyppi, esimerkiksi image/png.

Tyyppi on tarpeen, koska vastaanottaja ei voi päätellä sisältöä itse luotettavasti. Tiedostopääte on pelkkä osa nimeä, ja sen voi vaihtaa vaikuttamatta sisältöön. Selain ei siis luota päätteeseen vaan ilmoitettuun tyyppiin.

Samat tyypit tulevat vastaan monessa paikassa, ja siksi ne kannattaa oppia kertaalleen kunnolla:

MissäMihin tyyppiä käytetään
HTTP-otsake Content-Typepalvelin kertoo selaimelle, mitä se lähetti; selain kertoo palvelimelle, mitä se lähetti
fetch-pyyntöotsakkeessa kerrotaan lähetetyn rungon tyyppi
Drag and dropsetData- ja getData-metodien avain sekä types-lista
File-olion typeselaimen arvio pudotetun tai valitun tiedoston tyypistä
input type="file"accept-attribuutti rajaa valittavat tiedostot
Blob ja URL.createObjectURLtyyppi kerrotaan blobia luotaessa
object-elementtitype-attribuutti kertoo liitetyn sisällön tyypin
Lomakkeen lähetysenctype valitsee, miten kentät koodataan

Tavallisimmat tyypit:

MIME-tyyppiSisältö
text/plainpelkkä teksti — raahauksessa se muoto, jonka lähes kaikki osaavat lukea
text/htmlHTML-merkkaus
text/csstyylitiedosto
text/csvsarkain- tai pilkkueroteltu taulukko
text/uri-listosoitteita rivi kerrallaan — tämän saa kun raahaa linkin
text/javascriptjavascript-koodi
application/jsonJSON — tavallisin rajapintojen vastausmuoto
application/xmlXML; myös text/xml on käytössä
application/pdfPDF-dokumentti
application/octet-streamtuntematon binääridata — selain tarjoaa tallennusta
image/png, image/jpeg, image/webpbittikarttakuvat
image/svg+xmlSVG-kuva; huomaa +xml-pääte
multipart/form-datalomake, jossa on tiedostoja
application/x-www-form-urlencodedlomakkeen oletusmuoto ilman tiedostoja

Kuvien ja äänen kohdalla voi käyttää myös jokerimerkkiä, mutta vain siellä missä kyse on hyväksymisestä eikä sisällön kertomisesta — esimerkiksi tiedostokentän accept-attribuutissa:

<input type="file" accept="image/*" multiple="multiple" />

Oman tyypin saa keksiä. Vakiintunut tapa on aloittaa alityyppi merkeillä x-, mikä kertoo ettei tyyppi ole rekisteröity:

e.dataTransfer.setData("application/x-kurssi", kurssi.id);

Oma tyyppi on drag and dropissa hyödyllinen, koska sen perusteella voi erottaa oman sovelluksen sisäisen raahauksen ulkopuolelta tulevasta — ja kieltää pudotuksen kaikelta muulta.

Sivun sisäisessä siirrossa riittää tunniste. Raahattava elementti ja sen tiedot ovat jo sivulla, joten vastaanottaja hakee ne tunnisteen perusteella ja siirtää saman elementin uuteen paikkaan. Olion voi muuttaa JSON.stringify-funktiolla merkkijonoksi silloin, kun data lähtee sivulta pois, esimerkiksi toiseen ikkunaan tai sovellukseen. Silloin vastaanottaja saa kopion eikä alkuperäistä, eikä kopiosta enää tiedä, mikä sivun elementti tai olio tiedot antoi.

Kaikki types-listan alkiot eivät ole MIME-tyyppejä. Kun raahauksessa on tiedostoja, listalla on merkkijono Files. Se ei noudata MIME-muotoa vaan on rajapinnan oma erikoisarvo, ja sen varassa tiedostopudotus tunnistetaan:

kohde.addEventListener("dragover", function (e) {
  if (e.dataTransfer.types.includes("Files")) {
    e.preventDefault();
  }
});

Kts. Recommended drag types ja MIME types.

Suojattu tila

Tämä yllättää: getData palauttaa tyhjän merkkijonon kaikkialla paitsi drop-käsittelijässä. Raahauksen aikana data on suojatussa tilassa, jotta sivu ei voi urkkia sisältöä, jota käyttäjä vain kuljettaa sen yli.

Käytettävissä on silti types-lista, joten pudotuksen sallittavuus päätellään tyypeistä eikä arvoista:

kohde.addEventListener("dragover", function (e) {
  // arvoja ei voi lukea, mutta tyypit näkyvät
  if (!e.dataTransfer.types.includes("application/x-kurssi")) {
    return;   // ei sallita pudotusta: preventDefault jätetään kutsumatta
  }

  e.preventDefault();
});

Huomaa, miten pudotus kielletään: jättämällä preventDefault kutsumatta. Tämä on rajapinnan oudoimpia kohtia, ja sen ymmärtää helpommin kun muistaa että oletus on jo kielto.

effectAllowed ja dropEffect

Raahaus voi tarkoittaa siirtoa, kopiointia tai viittauksen luomista. Lähde kertoo mikä on sallittua ja kohde valitsee mitä se tekee. Kursori muuttuu valinnan mukaan.

// lähde: tämä data voidaan siirtää tai kopioida
lahde.addEventListener("dragstart", function (e) {
  e.dataTransfer.effectAllowed = "copyMove";
  e.dataTransfer.setData("text/plain", lahde.textContent);
});

// kohde: tähän pudottaminen kopioi
kohde.addEventListener("dragover", function (e) {
  e.preventDefault();
  e.dataTransfer.dropEffect = "copy";
});

Kts. Drag operations.

Raahauskuva

Selain näyttää raahauksen aikana puoliläpinäkyvän kuvan raahattavasta elementistä. Sen voi vaihtaa setDragImage-metodilla. Kuvan on oltava sivulla näkyvissä kutsun hetkellä, mikä tekee metodista kömpelön — piilotettu elementti ei kelpaa.

Kohteen korostus ja dragleave-ansa

Käyttäjälle on näytettävä, mihin kohtaan pudottaminen on mahdollista. Suoraviivainen toteutus näyttää tältä:

kohde.addEventListener("dragenter", function () {
  kohde.classList.add("pudotettavissa");
});

kohde.addEventListener("dragleave", function () {
  kohde.classList.remove("pudotettavissa");   // vilkkuu!
});

Tämä vilkkuu, jos kohteen sisällä on muuta merkkausta. dragenter ja dragleave laukeavat myös lapsielementeistä, ja koska ne kuplivat ylöspäin, kohde saa dragleave-tapahtuman joka kerta kun kursori siirtyy lapsen päälle — vaikka se pysyy koko ajan kohteen sisällä.

Kestävin korjaus on laskuri: pidetään kirjaa siitä, monessako sisäkkäisessä elementissä kursori on.

let syvyys = 0;

kohde.addEventListener("dragenter", function (e) {
  e.preventDefault();
  syvyys += 1;
  kohde.classList.add("pudotettavissa");
});

kohde.addEventListener("dragleave", function () {
  syvyys -= 1;

  // korostus poistetaan vasta kun kursori on poistunut kokonaan
  if (syvyys === 0) {
    kohde.classList.remove("pudotettavissa");
  }
});

kohde.addEventListener("drop", function (e) {
  e.preventDefault();
  syvyys = 0;
  kohde.classList.remove("pudotettavissa");
});

Laskuri on nollattava myös drop-käsittelijässä, koska pudotuksen jälkeen dragleave-tapahtumaa ei tule.

Kevyempi vaihtoehto on estää lapsielementeiltä tapahtumat kokonaan CSS:llä. Se riittää usein, mutta estää samalla lasten omat tapahtumat:

#kohde * {
  pointer-events: none;
}
Konsoli näyttää syvyyden muuttumisen, kun kursori liikkuu kohteen lapsielementtien yli. Kokeile poistaa syvyyden tarkistus dragleave-käsittelijästä ja liikuta kursoria kohteen sisällä: korostus alkaa vilkkua. Erillisenä sivuna: dd2.html.

Tiedostojen pudotus

Tämä on drag and dropin hyödyllisin käyttötapaus: käyttäjä pudottaa tiedoston työpöydältä sivulle. Pudotetut tiedostot löytyvät dataTransfer.files-ominaisuudesta, ja ne ovat samanlaisia File-olioita kuin lomakkeen tiedostokentästä saadut.

const alue = document.getElementById("pudotusalue");

alue.addEventListener("dragover", function (e) {
  e.preventDefault();
  e.dataTransfer.dropEffect = "copy";
});

alue.addEventListener("drop", async function (e) {
  // Tämä on PAKOLLINEN. Ilman sitä selain avaa pudotetun tiedoston
  // ja oma sivu katoaa.
  e.preventDefault();

  for (const tiedosto of e.dataTransfer.files) {
    console.log(tiedosto.name, tiedosto.type, tiedosto.size);

    if (!tiedosto.type.startsWith("image/")) {
      continue;
    }

    // Kuva sivulle. createObjectURL antaa väliaikaisen osoitteen,
    // joka on vapautettava kun sitä ei enää tarvita.
    const kuva = document.createElement("img");
    kuva.src = URL.createObjectURL(tiedosto);
    kuva.alt = tiedosto.name;

    kuva.addEventListener("load", function () {
      URL.revokeObjectURL(kuva.src);
    });

    alue.append(kuva);
  }
});

Tekstitiedoston sisällön saa suoraan lupauksena:

const sisalto = await tiedosto.text();

Palvelimelle tiedoston lähettää fetch yhdessä FormData-olion kanssa, aivan kuten lomakkeeltakin:

const data = new FormData();
data.append("tiedosto", tiedosto);

const vastaus = await fetch("/lataa", { method: "POST", body: data });

Tarjoa aina myös tavallinen tiedostokenttä. Pudotusalue on lisämukavuus, ei ainoa reitti — pelkkä pudotusalue on käyttökelvoton näppäimistöllä ja kosketusnäytöillä.

<div id="pudotusalue">
  <p>Pudota kuvat tähän tai valitse ne:</p>
  <p>
    <label for="tiedostot">Kuvat</label>
    <input type="file" id="tiedostot" name="tiedostot"
           accept="image/*" multiple="multiple" />
  </p>
</div>
Pudota kuvatiedostoja alueelle tai valitse ne tiedostokentästä — sama käsittelijä hoitaa molemmat. Konsoliin tulostuu jokaisen tiedoston nimi, tyyppi ja koko, ja muut kuin kuvat ohitetaan. Erillisenä sivuna: dd3.html.

Kts. myös DataTransferItem, jolla pääsee käsiksi myös pudotettuihin hakemistoihin.

Saavutettavuus

Raahaaminen vaatii tarkkaa motoriikkaa: osoitin on vietävä elementin päälle, painallus on pidettävä pohjassa ja osoitin on siirrettävä toiseen kohtaan yhtenä yhtenäisenä liikkeenä. Kaikki eivät tähän pysty. Erikoislaitteilla — pallohiirellä, pääosoittimella, katseenseurannalla tai puheohjatulla hiiriemulaattorilla — raahaaminen on kömpelöä ja virhealtista.

Siksi WCAG 2.2:n vaatimus 2.5.7 Dragging Movements (taso AA) edellyttää, että jokaiselle raahaamiseen perustuvalle toiminnolle on tarjottava vaihtoehto, joka onnistuu yhdellä osoittimella ilman raahaamista, ellei raahaaminen ole toiminnon kannalta olennaista.

Käytännössä tämä tarkoittaa painikkeita tai valikkoa. Listan järjestämisessä riittää ylös ja alas:

<li draggable="true">
  Ensimmäinen kohta
  <button type="button" class="ylos" aria-label="Siirrä ylös: Ensimmäinen kohta">↑</button>
  <button type="button" class="alas" aria-label="Siirrä alas: Ensimmäinen kohta">↓</button>
</li>
lista.addEventListener("click", function (e) {
  const nappi = e.target.closest("button");

  if (nappi === null) {
    return;
  }

  const kohta = nappi.closest("li");

  if (nappi.classList.contains("ylos") && kohta.previousElementSibling !== null) {
    // before siirtää elementin: samaa solmua ei voi olla kahdessa paikassa
    kohta.previousElementSibling.before(kohta);
  }
  else if (nappi.classList.contains("alas") && kohta.nextElementSibling !== null) {
    kohta.nextElementSibling.after(kohta);
  }

  // kerrotaan muutoksesta myös niille, jotka eivät näe sitä
  tiedota(`${kohta.textContent.trim()} siirretty kohtaan ${jarjestysluku(kohta)}`);
});

Huomaa kaksi asiaa. Painikkeiden nimilapuissa on mukana kohteen nimi, koska ruudunlukija lukee painikkeet myös irrallaan listasta — pelkkä nuoli tai "siirrä ylös" ei kerro mitä siirretään.

Toiseksi muutoksesta on kerrottava. Kun järjestys vaihtuu, näkevä käyttäjä huomaa sen heti, mutta ruudunlukijalle mikään ei muuttunut. Ilmoitus annetaan aria-live-alueella:

<p id="ilmoitus" aria-live="polite" class="vain-ruudunlukijalle"></p>
const tiedota = function (viesti) {
  document.getElementById("ilmoitus").textContent = viesti;
};

const jarjestysluku = function (kohta) {
  // indexOf tarvitsee taulukon: children on HTMLCollection
  return [...kohta.parentElement.children].indexOf(kohta) + 1;
};

Alue on piilotettava näkyvistä niin, että ruudunlukija silti lukee sen. display: none ei kelpaa, koska se piilottaa sisällön myös apuvälineiltä:

.vain-ruudunlukijalle {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

Sama ilmoitus kannattaa antaa myös onnistuneen pudotuksen jälkeen.

Näppäimistötuki ei yksin riitä tämän vaatimuksen täyttämiseen, vaan se kuuluu toisen vaatimuksen alaan. Vaihtoehdon on toimittava osoittimella, eli painikkeena tai valikkona.

Kosketusnäytöt ja pointer-tapahtumat

HTML:n drag and drop ei toimi kosketuksella luotettavasti. Osa mobiiliselaimista käynnistää raahauksen pitkällä painalluksella, osa ei lainkaan, ja toiminta vaihtelee selaimesta ja käyttöjärjestelmästä toiseen. Puhelimella tai tabletilla yllä olevien esimerkkien varaan ei siis voi rakentaa. Tämä on syytä kokeilla itse, ennen kuin rakentaa käyttöliittymän raahaamisen varaan. Kts. Can I use: Drag and Drop.

Sivun sisäiseen siirtelyyn kannattaa siksi käyttää pointer-tapahtumia.

Mitä pointer-tapahtumat ovat

Ennen pointer-tapahtumia osoitinlaitteita käsiteltiin kahdella erillisellä tapahtumajoukolla. Hiirelle oli mousedown, mousemove ja mouseup, kosketukselle touchstart, touchmove ja touchend. Kynälle ei ollut mitään. Sama toiminto oli siis kirjoitettava kahteen kertaan, ja kosketustapahtumat toimivat vielä eri logiikalla: niissä liikutaan useilla kosketuspisteillä yhtä aikaa.

Pointer-tapahtumat yhdistävät nämä yhdeksi malliksi. Osoitin on mikä tahansa laite, joka osoittaa pistettä ruudulla: hiiri, sormi, kynä tai vaikka katseenseuraimen kursori. Sama koodi toimii kaikilla, ja tapahtuma kertoo tarvittaessa millä laitteella sitä käytettiin.

Pointer-tapahtumat eivät korvaa hiiritapahtumia vaan tulevat niiden rinnalle. Yhteensopivuuden vuoksi selain laukaisee kosketuksen jälkeen myös hiiritapahtumat, joten samaan toimintoon ei pidä liittää käsittelijöitä kummallakin tavalla — muuten se suoritetaan kahdesti. Valitse jompikumpi.

Tapahtumat

Tapahtumat vastaavat hiiritapahtumia lähes yksi yhteen. Kaksi niistä ei kupli: pointerenter ja pointerleave, aivan kuten mouseenter ja mouseleave.

TapahtumaMilloinHiirivastine
pointeroverosoitin saapuu elementin päällemouseover
pointerentersama, mutta ei kuplimouseenter
pointerdownpainallus alkaa tai sormi koskettaamousedown
pointermoveosoitin liikkuumousemove
pointeruppainallus päättyy tai sormi nouseemouseup
pointercancelselain tai järjestelmä keskeytti eleen—
pointeroutosoitin poistuu elementin päältämouseout
pointerleavesama, mutta ei kuplimouseleave
gotpointercaptureelementti sai osoittimen kaappauksen—
lostpointercapturekaappaus päättyi—

pointercancel on se, joka helposti jää huomiotta. Se laukeaa kun ele keskeytyy eikä pointerup-tapahtumaa tule lainkaan: käyttöjärjestelmä ottaa eleen itselleen, laite kääntyy, puhelu saapuu, tai selain päättää että kyseessä oli sittenkin vieritys. Jos siivous on vain pointerup-käsittelijässä, käyttöliittymä jää jumiin raahaustilaan. Tämä on sama ajattelutapa kuin dragend-tapahtuman kanssa.

Osoittimen tiedot

Tapahtuma perii kaiken hiiritapahtumalta — clientX, clientY, button, buttons, target — ja lisää päälle osoitinlaitteen tiedot.

OminaisuusMerkitys
pointerIdosoittimen tunniste; jokainen sormi saa omansa
pointerType"mouse", "touch" tai "pen"
isPrimaryonko tämä ensimmäinen osoitin — monikosketuksessa vain yksi on
pressurepainallusvoima välillä 0–1; hiirellä 0,5 kun painike on pohjassa
width, heightkosketusalueen koko; sormella isompi kuin kynällä
tiltX, tiltY, twistkynän kallistus ja kierto

pointerId on tärkein näistä. Se erottaa osoittimet toisistaan, joten sen avulla voi käsitellä useaa sormea yhtä aikaa — ja se on myös se arvo, joka annetaan kaappaukselle.

pointerType-ominaisuuden avulla voi hienosäätää toimintaa laitteen mukaan, esimerkiksi kasvattaa tartuntapintaa sormelle:

kortti.addEventListener("pointerdown", function (e) {
  if (e.pointerType === "touch") {
    // sormi peittää kohteen, joten siirretään esikatselu sivuun
  }
});

Osoittimen kaappaus

Raahauksen toteuttamisen avain on setPointerCapture. Se ohjaa kaikki kyseisen osoittimen tapahtumat samaan elementtiin siihen asti, kunnes painallus päättyy.

Ilman kaappausta liike katkeaa heti kun osoitin ehtii raahattavan elementin ulkopuolelle — ja niin käy aina, koska elementti liikkuu osoittimen perässä hieman jäljessä. Vaihtoehto olisi kuunnella pointermove-tapahtumia koko dokumentista, mutta kaappaus on siistimpi: käsittelijät voi liittää siihen elementtiin, jota raahataan.

Kaappaus vapautuu itsestään pointerup- ja pointercancel-tapahtuman yhteydessä, joten sitä ei tarvitse purkaa käsin. Tarvittaessa se onnistuu releasePointerCapture-metodilla.

Kaappaus on sidottu elementin paikkaan DOM-puussa. Jos kaapattu elementti irrotetaan ja liitetään toiseen kohtaan, kaappaus vapautuu ja pointercancel laukeaa. Tämä tulee vastaan heti kun järjestellään listaa liikkeen aikana: jos kaappaus on otettu raahattavalle kohdalle, ele katkeaa ensimmäiseen siirtoon.

Ratkaisu on ottaa kaappaus elementille, joka pysyy paikallaan — listalle itselleen. Sen lapsia voi siirrellä vapaasti. Samalla pointermove- ja pointerup-käsittelijät voi liittää kertaalleen listaan sen sijaan, että ne liitettäisiin uudelleen joka raahauksen alussa.

Älä yhdistä draggable-attribuuttia ja pointer-tapahtumia. Jos raahattavalla elementillä on draggable="true", selain käynnistää hiirellä natiivin raahauksen, joka keskeyttää pointer-eleen ja laukaisee pointercancel-tapahtuman välittömästi. Kosketuksella ele toimii, hiirellä ei — mikä on hämmentävin mahdollinen vikatila. Valitse jompikumpi tekniikka.

touch-action

Kosketusnäytöillä selain käyttää eleitä itse: pyyhkäisy vierittää sivua ja kahden sormen nipistys zoomaa. Jos elementin päällä halutaan oma raahaus, selaimen omat eleet on kytkettävä pois — muutoin selain vierittää sivua eikä pointermove-tapahtumia tule lainkaan.

#kortti {
  /* selain ei käytä eleitä tämän elementin päällä */
  touch-action: none;
}

Arvo voi olla myös osittainen: touch-action: pan-y sallii pystysuuntaisen vierityksen mutta antaa vaakasuuntaiset eleet omalle koodille. Se on kätevä vaakasuuntaisissa karuselleissa.

Tätä ei voi tehdä javascriptilla. Määritys on annettava CSS:ssä, koska selain tarvitsee tiedon ennen kuin ele on alkanut.

Raahaus pointer-tapahtumilla

const kortti = document.getElementById("kortti");

kortti.addEventListener("pointerdown", function (e) {
  // vain ensisijainen osoitin: monikosketuksessa muut jätetään rauhaan
  if (!e.isPrimary) {
    return;
  }

  // Kaikki tämän osoittimen tapahtumat tulevat nyt tälle elementille,
  // vaikka osoitin liikkuisi sen ulkopuolelle.
  kortti.setPointerCapture(e.pointerId);

  const alkuX = e.clientX;
  const alkuY = e.clientY;

  const liikkuu = function (tapahtuma) {
    const dx = tapahtuma.clientX - alkuX;
    const dy = tapahtuma.clientY - alkuY;

    // transform on kevyt animoitava: se ei aiheuta uudelleenasemointia
    kortti.style.transform = `translate(${dx}px, ${dy}px)`;
  };

  const loppuu = function (tapahtuma) {
    kortti.removeEventListener("pointermove", liikkuu);
    kortti.removeEventListener("pointerup", loppuu);
    kortti.removeEventListener("pointercancel", loppuu);

    kortti.style.transform = "";

    // pointercancel tarkoittaa keskeytystä: silloin ei pudoteta mihinkään
    if (tapahtuma.type === "pointercancel") {
      return;
    }

    // Raahattava elementti on osoittimen alla, joten se on siirrettävä
    // hetkeksi pois tieltä ennen kuin katsotaan mikä alla on.
    kortti.style.pointerEvents = "none";
    const alla = document.elementFromPoint(tapahtuma.clientX, tapahtuma.clientY);
    kortti.style.pointerEvents = "";

    const kohde = alla === null ? null : alla.closest(".pudotuskohta");

    if (kohde !== null) {
      kohde.append(kortti);
    }
  };

  kortti.addEventListener("pointermove", liikkuu);
  kortti.addEventListener("pointerup", loppuu);
  kortti.addEventListener("pointercancel", loppuu);
});

Pudotuskohdan päättelyyn käytetään elementFromPoint-metodia. Raahattava elementti itse on osoittimen alla, joten se on siirrettävä hetkeksi pois tieltä asettamalla pointer-events: none — muuten metodi palauttaa aina saman elementin.

Tämä on selvästi enemmän työtä kuin HTML:n drag and drop. Vastineeksi saa toteutuksen, joka toimii kaikilla laitteilla — ja johon painikevaihtoehto on helppo lisätä, koska järjestyksen muuttaminen on jo omana funktionaan.

Kokoava esimerkki: sama järjestäminen onnistuu raahaamalla ja painikkeilla, ja molemmat kertovat muutoksesta aria-live-alueella. Kokeile myös kosketusnäytöllä — tämä toimii siellä luotettavasti, toisin kuin yllä olevat esimerkit. Erillisenä sivuna: dd4.html.

Lisätietoa

Käyttäjien kommentit

Kommentoi Lisää kommentti