Перетаскивание

Добавляем подвижность элементам и управляем их свойствами через перетаскивание

10 минут чтения

Содержание

О модуле

В JavaScript перетаскивание складывается из трёх событий, которые работают в связке. mousedown фиксирует момент нажатия и запоминает начальные координаты. mousemove отслеживает движение курсора и обновляет положение объекта в реальном времени. mouseup завершает перетаскивание и отпускает объект. Все три события нужно координировать между собой через общее состояние, обычно через переменную-флаг isDragging.

Есть момент, который часто ловит врасплох. На мобильных устройствах mouse события не работают. Вместо них браузер генерирует touch события: touchstart, touchmove, touchend. Координаты хранятся не в event.clientX, а в event.touches[0].clientX. Если вы хотите, чтобы перетаскивание работало на телефонах и планшетах, нужно слушать обе группы событий. Об этом подробнее в паттернах ниже.

Свободное перемещение

Объект следует за курсором, пока зажата кнопка мыши. Отпустили и объект остаётся там, где вы его оставили. Базовый паттерн перетаскивания, на котором строятся все остальные.

При mousedown запоминается смещение курсора относительно левого верхнего угла объекта. Это нужно для того, чтобы объект не прыгал к курсору в момент нажатия, а двигался от той точки, за которую его схватили.

Объяснение

Обработчик mousemove вешается на root, а не на сам объект. Если повесить на объект, то при быстром движении курсор может убежать с элемента, и перетаскивание оборвётся. root перехватывает движение по всей превью-зоне.

Координаты пересчитываются через root.getBoundingClientRect(), чтобы позиционирование работало относительно превью-зоны, а не окна.

Для мобильных устройств добавлены обработчики touchstart, touchmove и touchend. Координаты считываются из event.touches[0] вместо самого event. Вызов event.preventDefault() в touchmove предотвращает скролл страницы, пока палец двигает объект.

тяни
Перетащите квадрат

<div class="drag-free-box">тяни</div>
      

.drag-free-box {
  position: absolute;
  width: 70px; height: 70px;
  background: #fe3904;
  border-radius: 10px;
  cursor: grab;
}
 
.drag-free-box.dragging {
  cursor: grabbing;
  box-shadow: 0 8px 24px rgba(0,0,0,0.25);
  transform: scale(1.08);
}
      

const box = root.querySelector('.drag-free-box')
let isDragging = false
let offsetX = 0, offsetY = 0
 
box.addEventListener('mousedown', (e) => {
  isDragging = true
  const rect = box.getBoundingClientRect()
  offsetX = e.clientX - rect.left
  offsetY = e.clientY - rect.top
  box.classList.add('dragging')
})
 
root.addEventListener('mousemove', (e) => {
  if (!isDragging) return
  const rootRect = root.getBoundingClientRect()
  box.style.left = (e.clientX - rootRect.left - offsetX) + 'px'
  box.style.top = (e.clientY - rootRect.top - offsetY) + 'px'
})
 
root.addEventListener('mouseup', () => {
  isDragging = false
  box.classList.remove('dragging')
})
 
/* Для мобильных добавьте touchstart/touchmove/touchend.
   Координаты берутся из event.touches[0].clientX/Y.
   В touchmove обязателен event.preventDefault(),
   иначе страница будет скроллиться вместе
   с перетаскиванием. */
      

Без event.preventDefault() в обработчике touchmove мобильный браузер будет одновременно перетаскивать объект и скроллить страницу. Палец двигается вниз, объект едет вниз, и вся страница тоже едет. Всегда вызывайте preventDefault() для touchmove, когда перетаскивание активно

Перетаскивание с привязкой

Объект перемещается свободно, но при отпускании прилипает к ближайшей ячейке невидимой сетки. Движение остаётся плавным, а финальная позиция всегда аккуратная и выровненная. Это полезно для конструкторов, раскладок и любых ситуаций, где нужен порядок.

Во время перетаскивания объект двигается свободно, следуя за курсором. Привязка срабатывает только при mouseup. В этот момент текущие координаты округляются до ближайшего кратного размеру ячейки через Math.round(x / cellSize) * cellSize.

Объяснение

Сетка отрисована через CSS background с повторяющимся градиентом, чтобы зритель видел, к каким точкам привязывается объект. Плавность прилипания обеспечивается transition на элементе, который временно включается при отпускании и выключается при следующем захвате, чтобы не тормозить перемещение.

snap

<div class="drag-snap-box">snap</div>
      

/* Визуальная сетка через CSS-градиент */
[data-demo="drag-snap"] {
  background-image:
    linear-gradient(to right, rgba(255,255,255,0.06) 1px, transparent 1px),
    linear-gradient(to bottom, rgba(255,255,255,0.06) 1px, transparent 1px);
  background-size: 60px 60px;
}
 
.drag-snap-box {
  position: absolute;
  width: 60px; height: 60px;
  background: #9eff70;
  cursor: grab;
}
      

const cellSize = 60
 
// При mouseup — привязка к ближайшей ячейке
function endDrag() {
  isDragging = false
  const x = parseFloat(box.style.left) || 0
  const y = parseFloat(box.style.top) || 0
  const snappedX = Math.round(x / cellSize) * cellSize
  const snappedY = Math.round(y / cellSize) * cellSize
  box.style.transition = 'left 0.2s ease, top 0.2s ease'
  box.style.left = snappedX + 'px'
  box.style.top = snappedY + 'px'
}
 
/* Math.round округляет до ближайшего
   кратного cellSize. При перемещении
   transition отключается, чтобы объект
   не тормозил за курсором. */
      

Размер ячейки сетки удобно хранить в CSS-переменной --cell-size и считывать из JavaScript через getComputedStyle. Тогда дизайнер может менять шаг сетки в стилях, не трогая код

Перетаскивание с границами

Объект можно двигать только внутри заданной области. При достижении границы контейнера он останавливается и не выходит за пределы. Это ограничение делает взаимодействие предсказуемым и предотвращает ситуацию, когда зритель потерял элемент за краем экрана.

Объяснение

После вычисления новой позиции она зажимается через Math.max и Math.min в допустимый диапазон. Для левого края это Math.max(0, x), для правого это Math.min(containerWidth - elementWidth, x). То же самое для вертикальной оси.

Ширина и высота контейнера считываются через root.offsetWidth и root.offsetHeight. Размеры самого элемента считываются аналогично. Разница между ними определяет максимально допустимую позицию.

Визуально объект упирается в стенки контейнера. Для мягкости можно добавить лёгкий отскок или пружинящий эффект через CSS transition, но в базовом варианте достаточно жёсткого ограничения.

тяни

<div class="drag-bounds-box">тяни</div>
      

[data-demo="drag-bounds"] {
  border: 2px dashed rgba(255,255,255,0.15);
}
 
.drag-bounds-box {
  position: absolute;
  width: 70px; height: 70px;
  background: #82e1f1;
  cursor: grab;
}
      

function moveDrag(clientX, clientY) {
  if (!isDragging) return
  const rootRect = root.getBoundingClientRect()
  let x = clientX - rootRect.left - offsetX
  let y = clientY - rootRect.top - offsetY
 
  // Ограничение границами контейнера
  const maxX = root.offsetWidth - box.offsetWidth
  const maxY = root.offsetHeight - box.offsetHeight
  x = Math.max(0, Math.min(maxX, x))
  y = Math.max(0, Math.min(maxY, y))
 
  box.style.left = x + 'px'
  box.style.top = y + 'px'
}
/* Math.max(0, ...) не даёт уйти за левый/верхний край.
   Math.min(max, ...) не даёт уйти за правый/нижний. */
      

Зоны сброса

Объект можно перетащить на одну из нескольких целевых зон. Если объект отпущен над зоной, он приземляется в неё. Если мимо — возвращается на исходную позицию. Это паттерн для сортировок, категоризации, игровых механик внутри плаката.

Объяснение

При mouseup JavaScript проверяет, пересекается ли объект с какой-либо из целевых зон. Для этого сравниваются прямоугольники объекта и зон через getBoundingClientRect(). Если центр объекта попадает внутрь прямоугольника зоны, объект считается сброшенным туда.

При попадании объект анимированно перемещается в центр зоны через transition. Зона визуально подсвечивается для обратной связи. Если объект не попал ни в одну зону, он возвращается на начальную позицию, которая запоминается в момент mousedown.

Подсветка зон во время перетаскивания реализуется через проверку пересечения в mousemove. Зона, над которой сейчас находится объект, получает CSS-класс .drag-over, который добавляет ей рамку или фон.

зона 1
зона 2
зона 3

<div class="drag-item"></div>
<div class="drop-zone">зона 1</div>
<div class="drop-zone">зона 2</div>
<div class="drop-zone">зона 3</div>
      

.drop-zone {
  border: 2px dashed rgba(255,255,255,0.2);
  border-radius: 12px;
  transition: border-color 0.2s ease;
}
 
.drop-zone.drag-over {
  border-color: #9eff70;
  background: rgba(158,255,112,0.08);
}
 
.drop-zone.zone-filled {
  border-color: #9eff70;
  border-style: solid;
}
      

function endDrag() {
  const itemRect = item.getBoundingClientRect()
  const cx = itemRect.left + itemRect.width / 2
  const cy = itemRect.top + itemRect.height / 2
 
  zones.forEach(zone => {
    const zr = zone.getBoundingClientRect()
    const isOver = cx > zr.left && cx < zr.right
                && cy > zr.top && cy < zr.bottom
 
    if (isOver) {
      // Приземляемся в центр зоны
      const rootRect = root.getBoundingClientRect()
      item.style.left = (zr.left - rootRect.left
        + (zr.width - item.offsetWidth) / 2) + 'px'
      item.style.top = (zr.top - rootRect.top
        + (zr.height - item.offsetHeight) / 2) + 'px'
      zone.classList.add('zone-filled')
    }
  })
 
  if (!landed) {
    // Мимо — возвращаемся на старт
    item.style.left = startX + 'px'
    item.style.top = startY + 'px'
  }
}
/* getBoundingClientRect возвращает прямоугольник
   элемента. Сравниваем центр объекта
   с границами зон, чтобы определить попадание. */
      

На мобильных устройствах touchmove с preventDefault() блокирует скролл, что ожидаемо при перетаскивании. Но если зоны сброса расположены ниже видимой области, зритель не сможет до них доскроллить, потому что перетаскивание уже активно. Размещайте все зоны так, чтобы они были видны одновременно с перетаскиваемым объектом

Сортировка списка

Элементы списка можно менять местами перетаскиванием. Зритель хватает элемент, тянет его вверх или вниз, остальные элементы раздвигаются, освобождая место. При отпускании элемент занимает новую позицию. Паттерн для ранжирования, приоритизации, пользовательских порядков.

При захвате элемента создаётся его визуальная копия, которая следует за курсором, а оригинал получает полупрозрачность. Через mousemove JavaScript определяет, над каким элементом списка находится курсор, и вставляет плейсхолдер (пустое пространство) в нужное место через insertBefore.

Этот паттерн сложнее предыдущих и требует аккуратной работы с координатами. На мобильных устройствах обязателен preventDefault() в touchmove, иначе вместо сортировки страница будет скроллиться.

Объяснение

Определение целевой позиции основано на сравнении clientY курсора с серединами элементов списка. Если курсор выше середины элемента, плейсхолдер ставится перед ним. Если ниже, после него.

При mouseup визуальная копия удаляется, а оригинал перемещается на позицию плейсхолдера через insertBefore. Переупорядочивание происходит непосредственно в DOM, без отдельного массива данных.

Первый элемент
Второй элемент
Третий элемент
Четвёртый элемент
Пятый элемент

<div class="drag-sort-list">
  <div class="drag-sort-item">Первый</div>
  <div class="drag-sort-item">Второй</div>
  <div class="drag-sort-item">Третий</div>
  <div class="drag-sort-item">Четвёртый</div>
  <div class="drag-sort-item">Пятый</div>
</div>
      

.drag-sort-item {
  padding: 12px 16px;
  background: rgba(255,255,255,0.1);
  border: 1px solid rgba(255,255,255,0.15);
  border-radius: 8px;
  color: white;
  cursor: grab;
  transition: transform 0.15s ease, opacity 0.15s ease;
}
 
.drag-sort-item.sort-dragging {
  opacity: 0.4;
}
 
.drag-sort-placeholder {
  border: 2px dashed rgba(255,255,255,0.2);
  border-radius: 8px;
}
      

items.forEach(item => {
  item.addEventListener('mousedown', (e) => {
    draggedEl = item
    item.classList.add('sort-dragging')
 
    // Создаём плейсхолдер на месте элемента
    placeholder = document.createElement('div')
    placeholder.className = 'drag-sort-placeholder'
    placeholder.style.height = item.offsetHeight + 'px'
    list.insertBefore(placeholder, item.nextSibling)
  })
})
 
function handleMove(clientY) {
  // Определяем, над каким элементом курсор
  const siblings = [...list.querySelectorAll(
    '.drag-sort-item:not(.sort-dragging)'
  )]
  for (const sibling of siblings) {
    const mid = sibling.getBoundingClientRect().top
              + sibling.offsetHeight / 2
    if (clientY < mid) {
      list.insertBefore(placeholder, sibling)
      return
    }
  }
  list.appendChild(placeholder)
}
 
/* При mouseup элемент вставляется
   на место плейсхолдера через insertBefore.
   Переупорядочивание происходит в DOM —
   без отдельного массива данных. */
      

Для отзывчивости добавьте CSS transition: transform 0.15s ease на элементы списка. Когда они раздвигаются, чтобы освободить место, движение будет плавным, а не рывковым. Без transition перестановка выглядит механически