Мышь: Drag'n'Drop

Drag’n’Drop – это возможность захватить мышью элемент и перенести его. В свое время это было замечательным открытием в области интерфейсов, которое позволило упростить большое количество операций.

Перенос мышкой может заменить целую последовательность кликов. И, самое главное, он упрощает внешний вид интерфейса: функции, реализуемые через Drag’n’Drop, в ином случае потребовали бы дополнительных полей, виджетов и т.п.

Отличия от HTML5 Drag’n’Drop

В современном стандарте HTML5 есть поддержка Drag’n’Drop при помощи специальных событий.

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

Но в плане именно Drag’n’Drop у них есть существенные ограничения. Например, нельзя организовать перенос «только по горизонтали» или «только по вертикали». Также нельзя ограничить перенос внутри заданной зоны. Есть и другие интерфейсные задачи, которые такими встроенными событиями нереализуемы.

Поэтому здесь мы будем рассматривать Drag’n’Drop при помощи событий мыши.

Рассматриваемые приёмы, вообще говоря, применяются не только в Drag’n’Drop, но и для любых интерфейсных взаимодействий вида «захватить – потянуть – отпустить».

Алгоритм Drag’n’Drop

Основной алгоритм Drag’n’Drop выглядит так:

  1. Отслеживаем нажатие кнопки мыши на переносимом элементе при помощи события mousedown.
  2. При нажатии – подготовить элемент к перемещению.
  3. Далее отслеживаем движение мыши через mousemove и передвигаем переносимый элемент на новые координаты путём смены left/top и position:absolute.
  4. При отпускании кнопки мыши, то есть наступлении события mouseup – остановить перенос элемента и произвести все действия, связанные с окончанием Drag’n’Drop.

В следующем примере эти шаги реализованы для переноса мяча:

var ball = document.getElementById('ball');

ball.onmousedown = function(e) { // 1. отследить нажатие

  // подготовить к перемещению
  // 2. разместить на том же месте, но в абсолютных координатах
  ball.style.position = 'absolute';
  moveAt(e);
  // переместим в body, чтобы мяч был точно не внутри position:relative
  document.body.appendChild(ball);

  ball.style.zIndex = 1000; // показывать мяч над другими элементами

  // передвинуть мяч под координаты курсора
  // и сдвинуть на половину ширины/высоты для центрирования
  function moveAt(e) {
    ball.style.left = e.pageX - ball.offsetWidth / 2 + 'px';
    ball.style.top = e.pageY - ball.offsetHeight / 2 + 'px';
  }

  // 3, перемещать по экрану
  document.onmousemove = function(e) {
    moveAt(e);
  }

  // 4. отследить окончание переноса
  ball.onmouseup = function() {
    document.onmousemove = null;
    ball.onmouseup = null;
  }
}

Если запустить этот код, то мы заметим нечто странное. При начале переноса мяч «раздваивается» и переносится не сам мяч, а его «клон».

Это можно увидеть в действии внутри ифрейма:

Попробуйте перенести мяч мышкой и вы увидите описанное, довольно-таки странное, поведение.

Это потому, что браузер имеет свой собственный Drag’n’Drop, который автоматически запускается и вступает в конфликт с нашим. Это происходит именно для картинок и некоторых других элементов.

Его нужно отключить:

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

Теперь всё будет в порядке.

В действии (внутри ифрейма):

Ещё одна особенность правильного Drag’n’Drop – событие mousemove отслеживается на document, а не на ball.

С первого взгляда кажется, что мышь всегда над мячом и обработчик mousemove можно повесить на сам мяч, а не на документ.

Однако, на самом деле мышь во время переноса не всегда над мячом.

Вспомним, событие mousemove возникает хоть и часто, но не для каждого пикселя. Быстрое движение курсора вызовет mousemove уже не над мячом, а, например, в дальнем конце страницы.

Вот почему мы должны отслеживать mousemove на всём document.

Правильное позиционирование

В примерах выше мяч позиционируется в центре под курсором мыши:

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

Если поставить left/top ровно в pageX/pageY, то мячик прилипнет верхним-левым углом к курсору мыши. Будет некрасиво. Поэтому мы сдвигаем его на половину высоты/ширины, чтобы был центром под мышью. Уже лучше.

Но не идеально. В частности, в самом начале переноса, особенно если мячик «взят» за край – он резко «прыгает» центром под курсор мыши.

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

Где захватили, за ту «часть элемента» и переносим:

  1. Когда человек нажимает на мячик mousedown – курсор сдвинут относительно левого-верхнего угла мяча на расстояние, которое мы обозначим shiftX/shiftY. И нужно при переносе сохранить этот сдвиг.

    Получить значения shiftX/shiftY легко: достаточно вычесть из координат курсора pageX/pageY левую-верхнюю границу мячика, полученную при помощи функции getCoords.

    При Drag’n’Drop мы везде используем координаты относительно документа, так как они подходят в большем количестве ситуаций.

    Конечно же, не проблема перейти к координатам относительно окна, если это понадобится. Достаточно использовать position:fixed, elem.getBoundingClientRect() для определения координат и e.clientX/Y.

    // onmousedown
    shiftX = e.pageX - getCoords(ball).left;
    shiftY = e.pageY - getCoords(ball).top;
  2. Далее при переносе мяча мы располагаем его left/top с учетом сдвига, то есть вот так:

    // onmousemove
    ball.style.left = e.pageX - shiftX + 'px';
    ball.style.top = e.pageY - shiftY + 'px';

Итоговый код с правильным позиционированием:

var ball = document.getElementById('ball');

ball.onmousedown = function(e) {

  var coords = getCoords(ball);
  var shiftX = e.pageX - coords.left;
  var shiftY = e.pageY - coords.top;

  ball.style.position = 'absolute';
  document.body.appendChild(ball);
  moveAt(e);

  ball.style.zIndex = 1000; // над другими элементами

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

  document.onmousemove = function(e) {
    moveAt(e);
  };

  ball.onmouseup = function() {
    document.onmousemove = null;
    ball.onmouseup = null;
  };

}

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

В действии (внутри ифрейма):

Различие особенно заметно, если захватить мяч за правый-нижний угол. В предыдущем примере мячик «прыгнет» серединой под курсор, в этом – будет плавно переноситься с текущей позиции.

Итого

Мы рассмотрели «минимальный каркас» Drag'n'Drop.

Его компоненты:

  1. События ball.mousedowndocument.mousemoveball.mouseup.
  2. Передвижение с учётом изначального сдвига shiftX/shiftY.
  3. Отмена действия браузера по событию dragstart.

На этой основе можно сделать очень многое.

  • При mouseup можно обработать окончание переноса, произвести изменения в данных, если они нужны.
  • Во время самого переноса можно подсвечивать элементы, над которыми проходит элемент.
  • При обработке событий mousedown и mouseup можно использовать делегирование, так что одного обработчика достаточно для управления переносом в зоне с сотнями элементов.

Это и многое другое мы рассмотрим в статье про Drag’n’Drop объектов.

Задачи

важность: 5

Как можно видеть из HTML/CSS, слайдер – это DIV, подкрашенный фоном/градиентом, внутри которого находится другой DIV, оформленный как бегунок, с position:relative.

Бегунок немного поднят, и вылезает по высоте из родителя.

На этой основе мы реализуем горизонтальный Drag’n’Drop, ограниченный по ширине. Его особенность – в position:relative у переносимого элемента, т.е. координата ставится не абсолютная, а относительно родителя.

Открыть решение в песочнице.

Создайте слайдер:

Захватите мышкой синий бегунок и двигайте его, чтобы увидеть в работе.

Важно:

  • Слайдер должен нормально работать при резком движении мыши влево или вправо, за пределы полосы. При этом бегунок должен останавливаться четко в нужном конце полосы.
  • При нажатом бегунке мышь может выходить за пределы полосы слайдера, но слайдер пусть все равно работает (это удобно для пользователя).

Открыть песочницу для задачи.

важность: 5

В решении этой задачи для переноса мы используем координаты относительно окна и position:fixed. Так проще.

А по окончании – прибавляем прокрутку и делаем position:absolute, чтобы элемент был привязан к определённому месту в документе, а не в окне. Можно было и сразу position:absolute и оперировать в абсолютных координатах, но код был бы немного длиннее.

Детали решения расписаны в комментариях в исходном коде.

Открыть решение в песочнице.

В этой задаче вы можете проверить своё понимание сразу нескольких аспектов Drag’n’Drop.

Сделайте так, чтобы элементы с классом draggable можно было переносить мышкой. По окончании переноса элемент остаётся на том месте в документе, где его положили.

Требования к реализации:

  • Должен быть 1 обработчик на document, использующий делегирование.
  • Если элементы подносят к вертикальным краям окна – оно должно прокручиваться вниз/вверх.
  • Горизонтальной прокрутки в этой задаче не существует.
  • Элемент при переносе, даже при резких движениях мышкой, не должен попасть вне окна.

Футбольное поле в этой задаче слишком большое, чтобы показывать его здесь, поэтому откройте его, кликнув по ссылке ниже. Там же и подробное описание задачи (осторожно, винни-пух и супергерои!).

Демо в новом окне

Открыть песочницу для задачи.

Карта учебника

Комментарии

перед тем как писать…
  • Приветствуются комментарии, содержащие дополнения и вопросы по статье, и ответы на них.
  • Для одной строки кода используйте тег <code>, для нескольких строк кода — тег <pre>, если больше 10 строк — ссылку на песочницу (plnkr, JSBin, codepen…)
  • Если что-то непонятно в статье — пишите, что именно и с какого места.