Мышь: Drag’n’Drop расширенные возможности

Мышь: Drag’n’Drop расширенные возможности

Здравствуйте!  В прошлой статье мы рассмотрели основы Drag’n’Drop.  В этой статье я хотел бы  разобрать дополнительные приёмы реализации, которые  могут возникать на практике.

Надо сказать, что почти все javascript-библиотеки реализуют Drag’n’Drop так, как написано.

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

Данный материал предназначен в первую очередь для усвоения  основ drag’n’drop.

drag'n'drop расширенные возможности

Документ drag’n’drop

Например  возьмём документ с иконками браузера, котрые и будут объектами переноса и  которые можно переносить в компьютер:

  • Элементы, которые можно переносить (иконки браузеров),  будут помечены классом draggable.
  • Элементы, на которые можно положить (компьютер), будут иметь класс droppable.

Далее мы рассмотрим, как делается фреймворк для вот  таких переносов, а в перспективе – и для более сложных.
Требования:

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

Начало переноса элементов

Чтобы начать перенос элемента, мы отловим нажатие левой кнопки мыши на элементе.  Для этого потребуется   событие mousedown… И, конечно, же  делегирование.

Ведь переносимых элементов может быть очень  много. В нашем примере это всего лишь несколько иконок, но если вы хотите переносить элементы списка или таблицы, то  ведь их может быть 100 штук и более.

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

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

Найденный draggable-элемент сохраним в свойстве dragObject.elem и начнём собственно  двигать.

Вот код обработчика события mousedown:

let dragObject = {};
document.onmousedown = function(e) {
  if (e.which != 1) { // если кликнули правой кнопкой мыши
    return; // то он не запускает перенос
  }
  let elem = e.target.closest('.draggable');
  if (!elem) return; // не нашли, клик вне draggable-объекта
  // запомнить переносимый объект
  dragObject.elem = elem;
  // запоминаем координаты, с которых и будет начат перенос объекта
  dragObject.downX = e.pageX;
  dragObject.downY = e.pageY;
}
Не начинаем перенос по mousedown

Ранее мы по mousedown начинали перенос.

Но ведь на самом деле нажатие на элемент может вовсе и не означать, что его собираются куда-то двигать. Возможно, на нём просто кликают.

Это очень важное различие. Снимать элемент со своего места и куда-то двигать нужно только при переносе.

Чтобы отличать перенос от клика, в том числе – от клика, который сопровождается нечаянным перемещением на пару пикселей ( ну рука просто дрогнула), мы будем запоминать в объекте dragObject, какой элемент (elem) и где (downX/downY) был зажат, а начало переноса будем инициировать из события mousemove, если он был передвинут хотя бы на несколько пикселей.

Перенос элемента

Первой задачей обработчика события mousemove является инициировать начало переноса в том случае, если элемент передвинули в зажатом состоянии.

Ну а 2-ой задачей – отображать его перенос при передвижении мыши.

Обработчик будет иметь вот такой вид:

document.addEventListener("mousemove", function(e) {
  if (!dragObject.elem) return; // элемент не зажат

  if (!dragObject.avatar) { // элемент нажат, но пока не начали его двигать
    ...начать перенос, присвоить dragObject.avatar = переносимый элемент
  }

  ...отобразить перенос элемента...
});

Здесь вы видите новое свойство dragObject.avatar. При старте переноса «аватар» делается из элемента и сохраняется в свойство dragObject.avatar объекта dragObject.

«Аватар» – это элемент, который и перемещается по экрану.

Почему же не перемещать по экрану сам draggable-элемент? Зачем, вообще, нужен этот аватар?

Дело в том, что иногда сам элемент передвигать не очень неудобно,  потому, что он имеет большие размеры. А удобно создать некоторое визуальное представление элемента типа его копию, и его уже переносить. Аватар и дает такую возможность.

А в самом простейшем случае аватаром можно будет сделать и сам элемент, и это не повлечёт за собой  дополнительных расходов.

Визуальное перемещение аватара

Для того, чтобы отобразить перенос аватара, достаточно  задать ему свойство position: absolute и менять потом координаты left/top.

Для использования абсолютных координат относительно документа, аватар должен являться  прямым потомком BODY.

Читайте также  Мышь: Drag'n'Drop или перетаскивание элементов на веб-странице

Вот пример подготовки  аватара  к переносу:

// в начале переноса:
if (avatar.parentNode != document.body) {
  document.body.appendChild(avatar); // переместить в BODY, если надо
}
avatar.style.zIndex = 9999; // сделать, чтобы элемент был выше других
avatar.style.position = 'absolute';

… А затем его можно двигать:

// при каждом движении мыши
avatar.style.left = новая координата + 'px';
avatar.style.top = новая координата + 'px';

Как вычислять новые координаты left/top при переносе аватара?
Чтобы элемент сохранял свою текщую позицию под курсором, необходимо при нажатии запомнить его изначальный сдвиг относительно курсора, и сохранять его при переносе.
Они сдвиги вычисляются как расстояние между курсором и левой/верхней границей элемента при событии mousedown. Детали вычислений описаны в статье Мышь: Drag’n’Drop.
Таким образом, при  событии mousemove мы будем назначать элементу координаты курсора с учетом сдвига shiftX/shiftY:

avatar.style.left = e.pageX - shiftX + 'px';
avatar.style.top = e.pageY - shiftY + 'px';

Полный код mousemove

Код mousemove, решающий задачу начала переноса и позиционирования аватара:

document.onmousemove = function(e) { 
if (!dragObject.elem) return; // элемент не зажат 
if ( !dragObject.avatar ) { // если перенос не начат... // посчитать дистанцию, на которую переместился курсор мыши 
var moveX = e.pageX - dragObject.downX; 
var moveY = e.pageY - dragObject.downY; 
if ( Math.abs(moveX) < 3 && Math.abs(moveY) < 3 ) { return; // ничего не делать, мышь не передвинулась достаточно далеко } 
dragObject.avatar = createAvatar(e); // захватить элемент 
if (!dragObject.avatar) { dragObject = {}; // аватар создать не удалось, отмена переноса return; // возможно, нельзя захватить за эту часть элемента } 
// аватар создан успешно // создать вспомогательные свойства shiftX/shiftY 
var coords = getCoords(dragObject.avatar); 
dragObject.shiftX = dragObject.downX - coords.left; dragObject.shiftY = dragObject.downY - coords.top; startDrag(e); // отобразить начало переноса } 
// отобразить перенос объекта при каждом движении мыши 
dragObject.avatar.style.left = e.pageX - dragObject.shiftX + 'px'; 
dragObject.avatar.style.top = e.pageY - dragObject.shiftY + 'px'; 
return false; 
 }

Здесь используются 2 функции для начала переноса: createAvatar(e) и startDrag(e).
Функция createAvatar(e) создает сам аватар. В нашем случае в качестве аватара берется сам draggable элемент. После создания аватара в него записывается функция avatar.rollback, которая задает поведение при отмене переноса.

Как правило, отмена переноса влечет за собой и разрушение аватара, если это был клон, или возвращение его на прежнее место, если это был сам элемент.

В нашем случае для отмены переноса нужно просто запомнить старую позицию элемента и его родителя.

function createAvatar(e) {
  // запомнить старые свойства, чтобы вернуться к ним при отмене переноса
  let avatar = dragObject.elem;
  let old = {
    parent: avatar.parentNode,
    nextSibling: avatar.nextSibling,
    position: avatar.position || '',
    left: avatar.left || '',
    top: avatar.top || '',
    zIndex: avatar.zIndex || ''
  };

  // функция для отмены переноса
  avatar.rollback = function() {
    old.parent.insertBefore(avatar, old.nextSibling);
    avatar.style.position = old.position;
    avatar.style.left = old.left;
    avatar.style.top = old.top;
    avatar.style.zIndex = old.zIndex
  };

  return avatar;
}

Функция startDrag(e), которую вызывает событие mousemove, если видит, что элемент в «зажатом» состоянии перенесли на достаточно большое расстояние, запускает начало переноса и спозиционирует аватар на странице:

function startDrag(e) {
  let avatar = dragObject.avatar;

  document.body.appendChild(avatar);
  avatar.style.zIndex = 9999;
  avatar.style.position = 'absolute';
}

Окончание переноса

Окончание переноса происходит по событию mouseup.

Его обработчик можно поставить на аватаре,  поскольку аватар всегда под курсором и  событие mouseup происходит именно  на нем. Но для универсальности и  возможно большей гибкости поставим его, как и остальные, на  объект document.

Задача обработчика события  mouseup:

  1. Обработать перенос, если он идет (существует аватар)
  2. Очистить данные dragObject.

Это даст нам вот такой код:

           
            document.onmouseup = function(e) {
           // (1) обработать перенос, если он идет 
           if (dragObject.avatar) { finishDrag(e); } 
    // в конце mouseup перенос либо завершился, либо даже не начинался // (2) в любом случае очистим "состояние переноса" 
           dragObject dragObject = {}; }

Для завершения переноса в функции finishDrag(e) нам нужно просто понять, на каком элементе мы находимся, и если над droppable – обработать перенос, а нет – откатиться:

                            function finishDrag(e) { 
                                 var dropElem = findDroppable(e); 
                                 if (dropElem) { ... успешный перенос ... } 
                                 else { ... отмена переноса ... } 
                                 }

Определяем элемент под курсором или нет

Чтобы понять, над каким элементом мы остановились – используем метод  document.elementFromPoint(clientX, clientY), который мы обсуждали в  статье координаты элемента в окне. Этот метод получает координаты относительно окна и возвращает самый глубокий элемент, который там в окне находится.

Читайте также  События мыши: mouseover/out, mouseenter/leave

Функция findDroppable(event), описанная ниже, использует его и находит самый глубокий элемент с атрибутом droppable, находящиийся под курсором мыши:

// возвратит ближайший droppable или null
function findDroppable(event) {
  // взять элемент на данных координатах
  let elem = document.elementFromPoint(event.clientX, event.clientY);
  // найти ближайший сверху droppable
  return elem.closest('.droppable');
}

Обратите внимание – для elementFromPoint нужны координаты относительно окна clientX/clientY, а не pageX/pageY.

Вариант выше – предварительный. Он не будет работать. Если попробовать применить эту функцию, будет все время возвращать один и тот же элемент! А именно – текущий переносимый. Почему так происходит?
Все дело в том, что в процессе переноса под мышкой будет находится именно аватар. При начале переноса ему даже z-index ставится большой, чтобы он и был поверх всех остальных.

Аватар перекрывает остальные элементы. Поэтому функция document.elementFromPoint() увидит на текущих координатах именно его.

Чтобы это изменить, нужно либо поправить код переноса, чтобы аватар двигался рядом с курсором мыши, или дать аватару стиль pointer-events:none, либо:

  1. Скрыть аватар.
  2. Вызывать elementFromPoint.
  3. Показать аватар.

Напишем функцию findDroppable(event), которая это делает:

function findDroppable(event) {
  // спрячем переносимый элемент
  dragObject.avatar.hidden = true;
  // получить самый вложенный элемент под курсором мыши
  var elem = document.elementFromPoint(event.clientX, event.clientY);
  // показать переносимый элемент обратно
  dragObject.avatar.hidden = false;
  if (elem == null) {
    // такое возможно, если курсор мыши "вылетел" за границу окна
    return null;
  }
  return elem.closest('.droppable');
}

DragManager

Из фрагментов кода, рассмотренного выше, можно собрать такой себе мини-фреймворк.

Объект DragManager будет запоминать текущий переносимый объект и отслеживать его перенос.

Для его создания используем не обычный синтаксис {…}, а вызов new function. Это позволит прямо при создании объявить дополнительные переменные и функции в замыкании, которыми могут пользоваться методы объекта, а также назначить обработчики:

let DragManager = new function() {
  let dragObject = {};
  let self = this; // для доступа к себе из обработчиков
  function onMouseDown(e) { ... }
  function onMouseMove(e) { ... }
  function onMouseUp(e) { ... }
  document.onmousedown = onMouseDown;
  document.onmousemove = onMouseMove;
  document.onmouseup = onMouseUp;
  this.onDragEnd = function(dragObject, dropElem) { };
  this.onDragCancel = function(dragObject) { };
}

Всю работу будут выполнять обработчики onMouse*, которые оформлены как локальные функции. В данном случае они ставятся на document через on…, но это легко поменять на addEventListener.

Код функции onMouse* мы подробно рассмотрели ранее, но вы сможете также увидеть их в полном примере ниже.

Внутренний объект dragObject будет содержать информацию об объекте переноса.

У него будут следующие свойства, которые также разобраны выше:

elem
Текущий зажатый мышью объект, если есть (ставится в mousedown).
avatar
Элемент-аватар, который передвигается по странице.
downX/downY
Координаты, на которых был клик mousedown
shiftX/shiftY
Относительный сдвиг курсора от угла элемента, вспомогательное свойство вычисляется в начале переноса.

Задачей DragManager является общее управление переносом. Что же касается действий при его окончании – их должен назначить внешний код, который использует DragManager.

С использованием DragManager пример, с которого начиналась эта глава – перенос иконок браузеров в компьютер, реализуется совсем просто:

DragManager.onDragEnd = function(dragObject, dropElem) {
  // скрыть/удалить переносимый объект
  dragObject.elem.hidden = true;
  // успешный перенос, показать улыбку классом computer-smile
  dropElem.className = 'computer computer-smile';
  // убрать улыбку через 0.2 сек
  setTimeout(function() {
    dropElem.classList.remove('computer-smile');
  }, 200);
};

DragManager.onDragCancel = function(dragObject) {
  // откат переноса
  dragObject.avatar.rollback();
};

Расширения

Существует масса возможных применений Drag’n’Drop. Здесь мы не будем реализовывать их все, поскольку не стоит цель создать фреймворк-монстр.

Однако, мы рассмотрим их, чтобы, при необходимости, легко было написать то, что потребуется.

Захватывать элемент можно только за «ручку»

Часто бывает, что перенос должен быть инициирован только при захвате за определённую зону элемента. К примеру, модальное окно можно «взять», только захватив его за заголовок.

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

Если mousedown был внутри элемента, помеченного, к примеру, классом draghandle, то начинаем перенос, иначе – нет.

Проверка прав на droppable

Бывает и так, что не на любое место в droppable можно положить элемент.

Например: в админ-панели есть дерево всех объектов сайта: статей, разделов, посетителей и т.п.

  • В этом дереве есть узлы различных типов: «статьи», «разделы» и «пользователи».
  • Все узлы являются переносимыми объектами.
  • Узел «статья» (draggable) можно переносить в «раздел» (droppable), а узел «пользователи» – нельзя. Но и то и другое можно поместить в «корзину».
Читайте также  События мыши: отмена выделения

Здесь решение: можно переносить или нельзя зависит как раз от  «типа» переносимого объекта.

Есть и более сложные варианты, когда решение зависит от конкретного места в droppable, над которым посетитель отпустил кнопку мыши. К примеру, переносить в верхнюю часть можно, а в нижнюю – нет.

Эта задача решается добавлением проверки в findDroppable(e). Эта функция знает и об аватаре и о событии, включая координаты. При попытке положить в «неправильное» место функция findDroppable(e) должна возвращать null.

Однако, на практике бывают ситуации, когда решение «прямо сейчас» принять невозможно. Например, нужно сделать запрос на сервер: «А разрешено ли текущему посетителю производить такую операцию?»

Как при этом должен вести себя интерфейс? Можно, конечно сделать, чтобы элемент после отпускания кнопки мыши «завис» над droppable, ожидая ответа. Однако, такое решение неудобно в реализации и странновато выглядит для посетителя.

Как правило, применяют «оптимистичный» алгоритм, по которому мы считаем, что перенос обычно успешен, но при необходимости можем отменить его.

При нём посетитель кладет объект туда, куда он хочет, а затем, в коде onDragEnd:

  1. Визуально обрабатывается завершение переноса, как будто все ок.
  2. Производится асинхронный запрос к серверу, содержащий информацию о переносе.
  3. Сервер обрабатывает перенос и возвращает ответ, все ли в порядке.
  4. Если нет – выводится ошибка и возвращается avatar.rollback(). Аватар в этом случае должен предусматривать возможность отката после успешного завершения.

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

Подсветка текущего droppable

Удобно, когда пользователь во время переноса наглядно видит, куда он сейчас положит draggable. Например, текущий droppable (или его часть) подсвечиваются.

Для этого в DragManager можно добавить дополнительные методы интеграции с внешним кодом:

  • onDragEnter – будет вызываться при заходе на droppable, из onMouseMove.
  • onDragMove – при каждом передвижении внутри droppable, из onMouseMove.
  • onDragLeave – при выходе с droppable, из onMouseMove и onMouseUp.

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

Для этого код, который обрабатывает перенос, может «делить на части» droppable, к примеру, в соотношении 25% – 50% – 25%, и смотреть:

  • Если перенос в верхнюю четверть, то это – «над».
  • Если перенос в середину, то это «внутрь».
  • Если перенос в нижнюю четверть, то это – «под».

Текущий droppable и позиция относительно него при этом могут помечаться подсветкой и жирной чертой над/под, если требуется.

Анимация отмены переноса

Отмену переноса и возврат аватара на место можно красиво анимировать.

Один из частых вариантов – скольжение объекта обратно к исходному месту, откуда его взяли. Для этого достаточно поправить avatar.rollback().

Итого

Уточнённый алгоритм Drag’n’Drop:

  1. При mousedown запомнить координаты нажатия.
  2. При mousemove инициировать перенос, как только зажатый элемент передвинули на 3 пикселя или больше. Сообщить во внешний код вызовом onDragStart. При этом:
  • Создать аватар, если можно начать перенос элемента draggable с данной позиции курсора.
  • Переместить аватар по экрану, установив его новую позицию из e.pageX/pageY с учетом изначального сдвига элемента относительно курсора.
  • Сообщить во внешний код о текущем droppable под курсором и позиции над ним вызовами onDragEnter, onDragMove, onDragLeave.
  • При mouseup обработать завершение переноса. Элемент под аватаром получить по координатам, предварительно спрятав аватар. Сообщить во внешний код вызовом onDragEnd.

Получившаяся реализация Drag’n’Drop проста, эффективна, изящна.

 

 

Если вы нашли ошибку, пожалуйста, выделите фрагмент текста и нажмите Ctrl+Enter.

Плюсануть
Поделиться

Об авторе

admin administrator

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: