Drag-and-drop на JavaScript быстро
Разбираемся в drag-and-drop'e на JavaScript - быстро, без лишней воды, всяких библиотек и прочего.
Drag-and-drop - способ использования элементов на экране, при котором можно брать элементы и перетаскивать их куда-либо. Прямо как папки, ярлыки, файлы на рабочем столе вашего компьютера (и не только на рабочем столе).
Реализовать драг-н-дроп на JavaScript можно с помощью нативного API. Этот API так и называется - Drag and Drop API. Нам совершенно не нужны будут никакие библиотеки, например такие популярные как React DnD или React Beautiful DnD.
В этом уроке сделаем вместе список задач, в котором задачи можно будет перетаскивать между трёх колонок - «Сделать», «В процессе» и «Готово»:
Попробуйте перетащить любую задачу внизу окна из списка задач в любую из трёх колонок «Сделать», «В процессе» и «Готово». Потом попробуйте перетащить задачу обратно вниз. Так и работает драг-н-дроп, который будем делать в этом уроке
Но сперва разберём быстро небольшую теорию.
Элементы драг-н-дропа
В драг-н-дропе есть два вида элементов:
- Draggable или перетаскиваемые
- Droppable или сбрасываемые
Как сделать элемент перетаскиваемым? Всё просто - добавить HTML-элементу флаг draggable со значением true:
1<div draggable="true">Меня можно перетаскивать</div>Теперь при попытке взять и перетащить такой элемент будет отображаться встроенный в ваш браузер стиль перетаскиваемых элементов. Скорее всего это будет просто отображение курсора в виде захвата (grab):
Попробуйте взять текст и потащить куда-нибудь - появится курсор захвата. Никакие специальные стили для этого не были прописаны, это дефолтное поведение браузера
Вот так это выглядит в моём браузере на моём ноутбуке
С перетаскиваемыми элементами разобрались, а как сделать элемент сбрасываемым? Всё просто - никаких флагов тут вообще не надо добавлять, все элементы по умолчанию позволяют сбрасывать на себя перетаскиваемые элементы:
1<div>На меня можно сбрасывать</div>
2<div>И на меня можно сбрасывать</div>
3<div draggable="true">Вы не поверите! На меня тоже можно сбрасывать!</div>К сожалению, по умолчанию у сбрасываемых элементов нет никакого выделения во время драг-н-дропа - это нужно делать вручную. Попробуйте взять перетаскиваемый текст и перетащить в сбрасываемое окошко - увидите, что сбрасываемый элемент никак не меняется и не подсвечивается, в отличии от элемента, который мы перетаскиваем:
Откройте консоль разработчика в своём браузере, чтобы увидеть какие события когда срабатывают
Все элементы можно сделать перетаскиваемыми и сбрасываемыми - можно перетаскивать что угодно и сбрасывать куда угодно. Можно добавить ограничения и тогда не всё можно будет перетаскивать и сбрасывать куда угодно, в зависимости от ваших вводных данных и требований, но это уже другая история.
События и обработчики
Плохие новости: само по себе ничего не заработает после добавления флага draggable. Вы могли это заметить в небольшом демо чуть выше - мы начинаем перетаскивать элемент, сбрасываем его, но он остаётся на своём исходном месте.
Чтобы управлять процессом переноса элементов нам нужны будут обработчики событий. В API драг-н-дропа такие имеются. Все события там можно разделить грубо на две категории - события для перетаскиваемых элементов и события для сбрасываемых элементов. Из названия понятно, что в обработчиках каждой из категории нам нужно сделать что-то с элементами. Рассмотрим это более подробно.
Итак, есть следующие события для перетаскиваемых элементов (которые draggable):
dragstart/onDragStartdrag/onDragdragend/onDragEnd
И для сбрасываемых элементов (которые droppable) есть вот такие события:
dragenter/onDragEnterdragover/onDragOverleave/onDragLeaveиdrop/onDrop
Чтобы лучше понять за что отвечает каждое событие, предлагаю открыть консоль в браузере и поперетаскивать элемент в демо выше. В логах консоли вы увидите сообщения, которые будут выводиться в зависимости от того, какое событие будет срабатывать.
В нашем простом примере со списком задач нам потребуются не все эти события, а только часть из них. Прежде всего для перетаскиваемых элементов нам потребуется два события - onDragStart и onDragEnd.
С помощью onDragStart будем иницилизировать процесс перетаскивания:
1const handleDragStart = () => {
2 // Инициализация перетаскивания
3};О том, что же именно нужно делать в этой инициализации будет рассказано чуть ниже.
Второй обработчик onDragEnd понадобится только для визуала - с помощью этого события сделаем интерфейс драг-н-дропа более понятным. Об этом тоже чуть ниже.
Для сбрасываемых элементов понадобится тоже два обработчика - onDrop и onDragOver. В onDrop будет обрабатываться сброс и как мы будем это делать рассмотрим чуть ниже, а вот onDragOver нужен будет только для того, чтобы предотвратить поведение браузера по умолчанию с помощью метода preventDefault(). Если вы забудете указать обработчик onDragOver с отменой стандартного поведения браузера, то обработчик onDrop не будет работать.
1const handleDragOver = (event) => {
2 event.preventDefault();
3};Передаваемый объект в обработчики event будет объектом типа DragEvent, который наследует свойства от MouseEvent и соответственно типа Event. Нам это очень пригодится в дальнейшем.
Верстаем
С теорией всё, займёмся практикой и сперва чуть-чуть поверстаем. Нам нужен будет какой-то контейнер, в котором будет три колонки со статусом задач - «Сделать», «В процессе» и «Готово». Эти три колонки будут зонами сброса или droppable элементами:
1<div class="wrapper columns">
2 <div class="column">
3 Сделать
4 </div>
5 <div class="column">
6 В процессе
7 </div>
8 <div class="column">
9 Готово
10 </div>
11</div>
121.wrapper {
2 display: flex;
3 gap: 20px;
4}
5
6.column {
7 width: 33%;
8 height: 100px;
9 border: 2px dashed #000;
10}Под колонками со статусами разместим область с задачами - это будут наши перетаскиваемые или draggable элементы. Для них тоже нужен будет какой-то контейнер - эта область тоже будет зоной сброса, если мы захотим убрать задачу из любой колонки:
1<div class="wrapper tasks">
2 <div class="task" draggable="true">
3 Сделать то
4 </div>
5 <div class="task" draggable="true">
6 Сделать сё
7 </div>
8 <div class="task" draggable="true">
9 Сделать пятое
10 </div>
11 <div class="task" draggable="true">
12 Сделать десятое
13 </div>
14</div> 1
2.tasks {
3 justify-content: center;
4 margin: 10px 0;
5 background: grey;
6 padding: 10px;
7}
8
9.task {
10 padding: 5px;
11 background: purple;
12 color: white;
13}CSS-стили здесь и ниже могут быть какими ваша душа пожелает.
Обратите внимание, что список задач и колонки находятся в разных контейнерах - не обязательно, чтобы перетаскиваемые и сбрасываемые элементы находились на одном уровне. На самом деле эти области могут находитmся на совершенно разных уровнях вложенности в DOM-дереве.
В итоге HTML-разметка и CSS-стили будет выглядеть так:
1<div class="wrapper columns">
2 <div class="column">
3 Сделать
4 </div>
5 <div class="column">
6 В процессе
7 </div>
8 <div class="column">
9 Готово
10 </div>
11</div>
12
13<div class="wrapper tasks">
14 <div class="task" draggable="true">
15 Сделать то
16 </div>
17 <div class="task" draggable="true">
18 Сделать сё
19 </div>
20 <div class="task" draggable="true">
21 Сделать пятое
22 </div>
23 <div class="task" draggable="true">
24 Сделать десятое
25 </div>
26</div> 1.wrapper {
2 display: flex;
3 gap: 20px;
4}
5
6.column {
7 width: 33%;
8 height: 100px;
9 background: lightgrey;
10}
11
12.tasks {
13 justify-content: center;
14 margin: 10px 0;
15 background: grey;
16 padding: 10px;
17}
18
19.task {
20 padding: 5px;
21 background: purple;
22 color: white;
23}Пишем JavaScript
Приступим к самой интересной части, попишем чуть-чуть на JavaScript.
Когда мы начинаем перетаскивать элемент нам обязательно нужно проинициализировать данные, мы уже затрагивали это. Как это сделать? Нужно как-то пометить какую из задач мы начали перетаскивать. Будем делать это в обработчике handleDragStart.
В этот обработчик мы передаём объект event, который, как было сказано выше, является типом DragEvent. У него есть объект dataTransfer, который поможет нам связать перетаскиваемый элемент и сбрасываемый. Для этого нам воспользуемся методом, который предоставляет нам dataTransfer - setData(). Он позволяет передавать данные между перетаскиваемым элементом и зоной сброса. dataTransfer может хранить только строки. С помощью этого метода мы запишем информацию о задаче, которую мы начали перетаскивать, в нашем простом примере это просто сама задача. Не будем здесь хитрить и просто передадим текст элемента. Достать его можно изevent.target, подойдёт textContent или innerHTML:
1function handleDragStart(event) {
2 event.dataTransfer.setData("text/plain", event.target.textContent);
3 event.target.style.opacity = "0.4";
4}Помимо передачи данных, тут же изменим прозрачность перетаскиваемой задачи, чтобы визуально было видно какая из множества задач сейчас перетаскивается. Также изменение прозрачности нам поможет найти нужную задачу, когда нам это потребуется ниже, избавив от необходимости прописывать какие-либо дополнительные атрибуты или идентификаторы задачам и колонкам.
Раз уже мы меняем прозрачность перетаскиваемого элемента, надо где-то возвращать её назад, верно? А когда её возвращать назад? Правильно, когда мы сбрасываем задачу:
1function handleDragEnd(event) {
2 event.target.style.opacity = "1";
3}Теперь займёмся обработчиком сброса handleDrop. В handleDragStart мы записали информацию, а тут будем её читать. В этом нам поможет снова объектdataTransfer. Раз есть метод записи setData, значит есть и метод для чтения - getData(). Обязательно отключим снова поведение браузера по умолчанию здесь с помощью preventDefault(). Обработчик handleDragOver пока останется таким, каким мы его написали выше, чуть позже доработаем его под наши нужды. Вот что у нас получается:
1function handleDrop(event) {
2 event.preventDefault();
3 const data = event.dataTransfer.getData("text");
4};
5
6function handleDragOver(event) {
7 event.preventDefault();
8};Пока что драг-н-дроп не будет работать. Мы просто записываем задачу когда начинаем перетаскивать её и читаем, когда сбрасываем.
Теперь важно вспомнить, что, задачи можно перетаскивать как из области задач, так и между колонкам, а также задачи можно всегда вернуть обратно в область задач.
Поэтому, чтобы интерфейс заработал, нам нужно:
- Когда перетащили задачу и сбросили её - убрать её оттуда, откуда её начали перетаскивать (т.е. убрать как из колонки так и из области задач)
- Когда перетащили задачу и сбросили её - вставить туда куда сбросили (т.е. вставить как в колонку, там и в область задач)
Тут нам и пригодится трюк с прозрачностью. Выше мы уже добавили прозрачностьopacity = `0.4` задаче, которую начали перетаскивать. Теперь мы можем воспользоваться этим свойством и быстро найти перетаскивемую задачу следующим образом:
1const draggedElement = document.querySelector(".task[style*="opacity: 0.4"]");Обновим наш обработчик сброса, держа в голове ТЗ выше:
1function handleDrop(event) {
2 event.preventDefault();
3 const data = event.dataTransfer.getData("text/plain");
4
5 const draggedElement = document.querySelector(".task[style*="opacity: 0.4"]");
6
7 if (draggedElement) {
8 draggedElement.style.opacity = "1";
9
10 // Проверяем, куда сбрасываем: в колонку или обратно в контейнер
11 if (event.target.classList.contains("column")) {
12 // Сбрасываем в колонку
13 event.target.appendChild(draggedElement);
14 } else if (event.target === tasksContainer || event.target.classList.contains("tasks")) {
15 // Сбрасываем обратно в контейнер с задачами
16 tasksContainer.appendChild(draggedElement);
17 }
18 }
19}Пока что у нас есть только обработчики. Нужно подписаться на них. Сделаем это классическим способом - с помощью метода querySelectorAll получим список всех задач и колонок, а потом пройдёмся по этим массивам методом перебора forEach и подпишемся на нужные обработчики задач и колонок:
1const tasks = document.querySelectorAll(".task");
2const columns = document.querySelectorAll(".column");
3
4tasks.forEach(function (task) {
5 task.addEventListener("dragstart", handleDragStart);
6 task.addEventListener("dragend", handleDragEnd);
7});
8
9columns.forEach(function (column) {
10 column.addEventListener("drop", handleDrop);
11 column.addEventListener("dragover", handleDragOver);
12});Забыли про возврат задач в область задач! Сейчас быстренько исправим. Аналогичным образом получим контейнер области задач и подпишем его на такие же обработчики, как и у колонок, но чуть-чуть модернизируемhandleDragOver:
1const tasksContainer = document.querySelector(".wrapper.tasks");
2
3tasksContainer.addEventListener("drop", handleDrop);
4tasksContainer.addEventListener("dragover", handleDragOver);
5
6function handleDragOver(event) {
7 // Разрешаем сброс на колонки И на контейнер задач
8 if (event.target.classList.contains("column") ||
9 event.target === tasksContainer ||
10 event.target.classList.contains("tasks")) {
11 event.preventDefault();
12 }
13}Собираем всё вместе. Разметка будет выглядеть так (тут ничего не изменилось с самого начала):
1<div class="wrapper columns">
2 <div class="column">
3 Сделать
4 </div>
5 <div class="column">
6 В процессе
7 </div>
8 <div class="column">
9 Готово
10 </div>
11</div>
12
13<div class="wrapper tasks">
14 <div class="task" draggable="true">
15 Сделать то
16 </div>
17 <div class="task" draggable="true">
18 Сделать сё
19 </div>
20 <div class="task" draggable="true">
21 Сделать пятое
22 </div>
23 <div class="task" draggable="true">
24 Сделать десятое
25 </div>
26</div> Разметку CSS здесь не буду прикладывать т.к. она совершенно не влияет на работоспособность кода и вы можете стилизовать список задач как ваша душа захочет. И, наконец-то, JavaScript:
1const tasks = document.querySelectorAll(".task");
2const columns = document.querySelectorAll(".column");
3const tasksContainer = document.querySelector(".wrapper.tasks");
4
5function handleDragStart(event) {
6 event.dataTransfer.setData("text/plain", event.target.textContent);
7 event.target.style.opacity = "0.4";
8}
9
10function handleDragEnd(event) {
11 event.target.style.opacity = "1";
12}
13
14function handleDrop(event) {
15 event.preventDefault();
16 const data = event.dataTransfer.getData("text/plain");
17
18 const draggedElement = document.querySelector(".task[style*="opacity: 0.4"]");
19
20 if (draggedElement) {
21 draggedElement.style.opacity = "1";
22
23 // Проверяем, куда сбрасываем: в колонку или обратно в контейнер
24 if (event.target.classList.contains("column")) {
25 // Сбрасываем в колонку
26 event.target.appendChild(draggedElement);
27 } else if (event.target === tasksContainer || event.target.classList.contains("tasks")) {
28 // Сбрасываем обратно в контейнер с задачами
29 tasksContainer.appendChild(draggedElement);
30 }
31 }
32}
33
34function handleDragOver(event) {
35 // Разрешаем сброс на колонки И на контейнер задач
36 if (event.target.classList.contains("column") ||
37 event.target === tasksContainer ||
38 event.target.classList.contains("tasks")) {
39 event.preventDefault();
40 }
41}
42
43// Вешаем обработчики на задачи
44tasks.forEach(function (task) {
45 task.addEventListener("dragstart", handleDragStart);
46 task.addEventListener("dragend", handleDragEnd);
47});
48
49// Вешаем обработчики на колонки
50columns.forEach(function (column) {
51 column.addEventListener("drop", handleDrop);
52 column.addEventListener("dragover", handleDragOver);
53});
54
55// Вешаем обработчики на контейнер задач
56tasksContainer.addEventListener("drop", handleDrop);
57tasksContainer.addEventListener("dragover", handleDragOver);Вы можете перейти по ссылке в песочницу на Codepen, чтобы вживую потыкать сделанный в этом уроке список задач с нативным драг-н-дропом на JavaScript.