Эта статья представляет собой продолжение главы Мышь: Drag'n'Drop более глубоко. Она посвящена более гибкой и расширяемой реализации переноса.
Рекомендуется прочитать указанную главу перед тем, как двигаться дальше.
В сложных приложениях Drag’n’Drop обладает рядом особенностей:
-
Перетаскиваются элементы из зоны переноса
dragZone
в зону-цельdropTarget
. При этом сама зона не переносится.Например – два списка, нужен перенос элемента из одного в другой. В этом случае один список является зоной переноса, второй – зоной-целью.
Возможно, что перенос осуществляется внутри одного и того же списка. При этом
dragZone == dropTarget
. -
На странице может быть несколько разных зон переноса и зон-целей.
-
Обработка завершения переноса может быть асинхронной, с уведомлением сервера.
-
Должно быть легко добавить новый тип зоны переноса или зоны-цели, а также расширить поведение существующей.
-
Фреймворк для переноса должен быть расширяемым с учётом сложных сценариев.
Всё это вполне реализуемо. Но для этого фреймворк, описанный в статье Мышь: Drag'n'Drop более глубоко, нужно отрефакторить, и разделить на сущности.
Основные сущности
Всего будет 4 сущности:
DragZone
- Зона переноса. С неё начинается перенос. Она принимает нажатие мыши и генерирует аватар нужного типа.
DragAvatar
- Переносимый объект. Предоставляет доступ к информации о том, что переносится. Умеет двигать себя по экрану. В зависимости от вида переноса, может что-то делать с собой в конце, например, самоуничтожаться.
DropTarget
- Зона-цель, на которую можно положить. В процессе переноса аватара над ней умеет рисовать на себе предполагаемое «место приземления». Обрабатывает окончание переноса.
dragManager
- Единый объект, который стоит над всеми ними, ставит обработчики
mousedown/mousemove/mouseup
и управляет процессом. В терминах ООП, это не класс, а объект-синглтон, поэтому он с маленькой буквы.
На макете страницы ниже возможен перенос студентов из левого списка – вправо, в одну из команд или в «корзину»:
Здесь левый список является зоной переноса ListDragZone
, а правые списки – это несколько зон-целей ListDropTarget
. Кроме того, корзина также является зоной-целью отдельного типа RemoveDropTarget
.
Пример
В этой статье мы реализуем пример, когда узлы дерева можно переносить внутри него. То есть, дерево, которое является одновременно TreeDragZone
и TreeDropTarget
.
Структура дерева будет состоять из вложенных списков с заголовком в SPAN
:
<ul>
<li><span>Заголовок 1</span>
<ul>
<li><span>Заголовок 1.1</span></li>
<li><span>Заголовок 1.2</span></li>
...
</ul>
</li>
...
</ul>
При переносе:
- Для аватара нужно клонировать заголовок узла, на котором было нажатие.
- Узлы, на которые можно положить, при переносе подсвечиваются красным.
- Нельзя перенести узел сам в себя или в своего потомка.
- Дерево само поддерживает сортировку по алфавиту среди узлов.
- Обязательна расширяемость кода, поддержка большого количества узлов и т.п.
dragManager
Обязанность dragManager
– обработка событий мыши и координация всех остальных сущностей в процессе переноса.
Готовьтесь, дальше будет много кода с комментариями.
Следующий код должен быть очевиден по смыслу, если вы читали предыдущую статью. Объект взят оттуда, и из него изъята лишняя функциональность, которая перенесена в другие сущности.
Если вызываемые в нём методы onDrag*
непонятны – смотрите далее, в описание остальных объектов.
var dragManager = new function() {
var dragZone, avatar, dropTarget;
var downX, downY;
var self = this;
function onMouseDown(e) {
if (e.which != 1) { // не левой кнопкой
return false;
}
dragZone = findDragZone(e);
if (!dragZone) {
return;
}
// запомним, что элемент нажат на текущих координатах pageX/pageY
downX = e.pageX;
downY = e.pageY;
return false;
}
function onMouseMove(e) {
if (!dragZone) return; // элемент не зажат
if (!avatar) { // элемент нажат, но пока не начали его двигать
if (Math.abs(e.pageX - downX) < 3 && Math.abs(e.pageY - downY) < 3) {
return;
}
// попробовать захватить элемент
avatar = dragZone.onDragStart(downX, downY, e);
if (!avatar) { // не получилось, значит перенос продолжать нельзя
cleanUp(); // очистить приватные переменные, связанные с переносом
return;
}
}
// отобразить перенос объекта, перевычислить текущий элемент под курсором
avatar.onDragMove(e);
// найти новый dropTarget под курсором: newDropTarget
// текущий dropTarget остался от прошлого mousemove
// *оба значения: и newDropTarget и dropTarget могут быть null
var newDropTarget = findDropTarget(e);
if (newDropTarget != dropTarget) {
// уведомить старую и новую зоны-цели о том, что с них ушли/на них зашли
dropTarget && dropTarget.onDragLeave(newDropTarget, avatar, e);
newDropTarget && newDropTarget.onDragEnter(dropTarget, avatar, e);
}
dropTarget = newDropTarget;
dropTarget && dropTarget.onDragMove(avatar, e);
return false;
}
function onMouseUp(e) {
if (e.which != 1) { // не левой кнопкой
return false;
}
if (avatar) { // если уже начали передвигать
if (dropTarget) {
// завершить перенос и избавиться от аватара, если это нужно
// эта функция обязана вызвать avatar.onDragEnd/onDragCancel
dropTarget.onDragEnd(avatar, e);
} else {
avatar.onDragCancel();
}
}
cleanUp();
}
function cleanUp() {
// очистить все промежуточные объекты
dragZone = avatar = dropTarget = null;
}
function findDragZone(event) {
var elem = event.target;
while (elem != document && !elem.dragZone) {
elem = elem.parentNode;
}
return elem.dragZone;
}
function findDropTarget(event) {
// получить элемент под аватаром
var elem = avatar.getTargetElem();
while (elem != document && !elem.dropTarget) {
elem = elem.parentNode;
}
if (!elem.dropTarget) {
return null;
}
return elem.dropTarget;
}
document.ondragstart = function() {
return false;
}
document.onmousemove = onMouseMove;
document.onmouseup = onMouseUp;
document.onmousedown = onMouseDown;
};
DragZone
Основная задача DragZone
– создать аватар и инициализировать его. В зависимости от места, где произошёл клик, аватар получит соответствующий подэлемент зоны.
Метод для создания аватара _makeAvatar
вынесен отдельно, чтобы его легко можно было переопределить и подставить собственный тип аватара.
/**
* Зона, из которой можно переносить объекты
* Умеет обрабатывать начало переноса на себе и создавать "аватар"
* @param elem DOM-элемент, к которому привязана зона
*/
function DragZone(elem) {
elem.dragZone = this;
this._elem = elem;
}
/**
* Создать аватар, соответствующий зоне.
* У разных зон могут быть разные типы аватаров
*/
DragZone.prototype._makeAvatar = function() {
/* override */
};
/**
* Обработать начало переноса.
*
* Получает координаты изначального нажатия мышки, событие.
*
* @param downX Координата изначального нажатия по X
* @param downY Координата изначального нажатия по Y
* @param event текущее событие мыши
*
* @return аватар или false, если захватить с данной точки ничего нельзя
*/
DragZone.prototype.onDragStart = function(downX, downY, event) {
var avatar = this._makeAvatar();
if (!avatar.initFromEvent(downX, downY, event)) {
return false;
}
return avatar;
};
TreeDragZone
Объект зоны переноса для дерева, по существу, не вносит ничего нового, по сравнению с DragZone
.
Он только переопределяет _makeAvatar
для создания TreeDragAvatar
.
function TreeDragZone(elem) {
DragZone.apply(this, arguments);
}
extend(TreeDragZone, DragZone);
TreeDragZone.prototype._makeAvatar = function() {
return new TreeDragAvatar(this, this._elem);
};
DragAvatar
Аватар создаётся только зоной переноса при начале Drag’n’Drop. Он содержит всю необходимую информацию об объекте, который переносится.
В дальнейшем вся работа происходит только с аватаром, сама зона напрямую не вызывается.
У аватара есть три основных свойства:
_dragZone
-
Зона переноса, которая его создала.
_dragZoneElem
-
Элемент, соответствующий аватару в зоне переноса. По умолчанию – DOM-элемент всей зоны. Это подходит в тех случаях, когда зона перетаскивается только целиком. При инициализации аватара значение этого свойства может быть уточнено, например изменено на подэлемент списка, который перетаскивается.
_elem
-
Основной элемент аватара, который будет двигаться по экрану. По умолчанию равен
_dragZoneElem
, т.е мы переносим сам элемент.При инициализации мы можем также склонировать
_dragZoneElem
, или создать своё красивое представление переносимого элемента и поместить его в_elem
.
/**
* "Аватар" - элемент, который перетаскивается.
*
* В простейшем случае аватаром является сам переносимый элемент
* Также аватар может быть клонированным элементом
* Также аватар может быть иконкой и вообще чем угодно.
*/
function DragAvatar(dragZone, dragElem) {
/** "родительская" зона переноса */
this._dragZone = dragZone;
/**
* подэлемент родительской зоны, к которому относится аватар
* по умолчанию - элемент, соответствующий всей зоне
* может быть уточнен в initFromEvent
*/
this._dragZoneElem = dragElem;
/**
* Сам элемент аватара, который будет носиться по экрану.
* Инициализуется в initFromEvent
*/
this._elem = dragElem;
}
/**
* Инициализировать this._elem и позиционировать его
* При необходимости уточнить this._dragZoneElem
* @param downX Координата X нажатия мыши
* @param downY Координата Y нажатия мыши
* @param event Текущее событие мыши
*/
DragAvatar.prototype.initFromEvent = function(downX, downY, event) {
/* override */
};
/**
* Возвращает информацию о переносимом элементе для DropTarget
* @param event
*/
DragAvatar.prototype.getDragInfo = function(event) {
// тут может быть еще какая-то информация, необходимая для обработки конца или процесса переноса
return {
elem: this._elem,
dragZoneElem: this._dragZoneElem,
dragZone: this._dragZone
};
};
/**
* Возвращает текущий самый глубокий DOM-элемент под this._elem
* Приватное свойство _currentTargetElem обновляется при каждом передвижении
*/
DragAvatar.prototype.getTargetElem = function() {
return this._currentTargetElem;
};
/**
* При каждом движении мыши перемещает this._elem
* и записывает текущий элемент под this._elem в _currentTargetElem
* @param event
*/
DragAvatar.prototype.onDragMove = function(event) {
this._elem.style.left = event.pageX - this._shiftX + 'px';
this._elem.style.top = event.pageY - this._shiftY + 'px';
this._currentTargetElem = getElementUnderClientXY(this._elem, event.clientX, event.clientY);
};
/**
* Действия с аватаром, когда перенос не удался
* Например, можно вернуть элемент обратно или уничтожить
*/
DragAvatar.prototype.onDragCancel = function() {
/* override */
};
/**
* Действия с аватаром после успешного переноса
*/
DragAvatar.prototype.onDragEnd = function() {
/* override */
};
TreeDragAvatar
Основные изменения – в методе initFromEvent
, который создаёт аватар из узла, на котором был клик.
Обратите внимание, возможно что клик был не на заголовке SPAN
, а просто где-то на дереве. В этом случае initFromEvent
возвращает false
и перенос не начинается.
function TreeDragAvatar(dragZone, dragElem) {
DragAvatar.apply(this, arguments);
}
extend(TreeDragAvatar, DragAvatar);
TreeDragAvatar.prototype.initFromEvent = function(downX, downY, event) {
if (event.target.tagName != 'SPAN') return false;
this._dragZoneElem = event.target;
var elem = this._elem = this._dragZoneElem.cloneNode(true);
elem.className = 'avatar';
// создать вспомогательные свойства shiftX/shiftY
var coords = getCoords(this._dragZoneElem);
this._shiftX = downX - coords.left;
this._shiftY = downY - coords.top;
// инициировать начало переноса
document.body.appendChild(elem);
elem.style.zIndex = 9999;
elem.style.position = 'absolute';
return true;
};
/**
* Вспомогательный метод
*/
TreeDragAvatar.prototype._destroy = function() {
this._elem.parentNode.removeChild(this._elem);
};
/**
* При любом исходе переноса элемент-клон больше не нужен
*/
TreeDragAvatar.prototype.onDragCancel = function() {
this._destroy();
};
TreeDragAvatar.prototype.onDragEnd = function() {
this._destroy();
};
DropTarget
Именно на DropTarget
ложится работа по отображению предполагаемой «точки приземления» аватара, а также, по завершению переноса, обработка результата.
Как правило, DropTarget
принимает переносимый узел в себя, а вот как конкретно организован процесс вставки – нужно описать в классе-наследнике. Разные типы зон делают разное при вставке: TreeDropTarget
вставляет элемент в качестве потомка, а RemoveDropTarget
– удаляет.
/**
* Зона, в которую объекты можно класть
* Занимается индикацией передвижения по себе, добавлением в себя
*/
function DropTarget(elem) {
elem.dropTarget = this;
this._elem = elem;
/**
* Подэлемент, над которым в настоящий момент находится аватар
*/
this._targetElem = null;
}
/**
* Возвращает DOM-подэлемент, над которым сейчас пролетает аватар
*
* @return DOM-элемент, на который можно положить или undefined
*/
DropTarget.prototype._getTargetElem = function(avatar, event) {
return this._elem;
};
/**
* Спрятать индикацию переноса
* Вызывается, когда аватар уходит с текущего this._targetElem
*/
DropTarget.prototype._hideHoverIndication = function(avatar) {
/* override */
};
/**
* Показать индикацию переноса
* Вызывается, когда аватар пришел на новый this._targetElem
*/
DropTarget.prototype._showHoverIndication = function(avatar) {
/* override */
};
/**
* Метод вызывается при каждом движении аватара
*/
DropTarget.prototype.onDragMove = function(avatar, event) {
var newTargetElem = this._getTargetElem(avatar, event);
if (this._targetElem != newTargetElem) {
this._hideHoverIndication(avatar);
this._targetElem = newTargetElem;
this._showHoverIndication(avatar);
}
};
/**
* Завершение переноса.
* Алгоритм обработки (переопределить функцию и написать в потомке):
* 1. Получить данные переноса из avatar.getDragInfo()
* 2. Определить, возможен ли перенос на _targetElem (если он есть)
* 3. Вызвать avatar.onDragEnd() или avatar.onDragCancel()
* Если нужно подтвердить перенос запросом на сервер, то avatar.onDragEnd(),
* а затем асинхронно, если сервер вернул ошибку, avatar.onDragCancel()
* При этом аватар должен уметь "откатываться" после onDragEnd.
*
* При любом завершении этого метода нужно (делается ниже):
* снять текущую индикацию переноса
* обнулить this._targetElem
*/
DropTarget.prototype.onDragEnd = function(avatar, event) {
this._hideHoverIndication(avatar);
this._targetElem = null;
};
/**
* Вход аватара в DropTarget
*/
DropTarget.prototype.onDragEnter = function(fromDropTarget, avatar, event) {};
/**
* Выход аватара из DropTarget
*/
DropTarget.prototype.onDragLeave = function(toDropTarget, avatar, event) {
this._hideHoverIndication();
this._targetElem = null;
};
Как видно, из кода выше, по умолчанию DropTarget
занимается только отслеживанием и индикацией «точки приземления». По умолчанию, единственной возможной «точкой приземления» является сам элемент зоны. В более сложных ситуациях это может быть подэлемент.
Для применения в реальности необходимо как минимум переопределить обработку результата переноса в onDragEnd
.
TreeDropTarget
TreeDropTarget
содержит код, специфичный для дерева:
- Индикацию переноса над элементом: методы
_showHoverIndication
и_hideHoverIndication
. - Получение текущей точки приземления
_targetElem
в методе_getTargetElem
. Ей может быть только заголовок узла дерева, причём дополнительно проверяется, что это не потомок переносимого узла. - Обработка успешного переноса в
onDragEnd
, вставка исходного узлаavatar.dragZoneElem
в узел, соответствующий_targetElem
.
function TreeDropTarget(elem) {
TreeDropTarget.parent.constructor.apply(this, arguments);
}
extend(TreeDropTarget, DropTarget);
TreeDropTarget.prototype._showHoverIndication = function() {
this._targetElem && this._targetElem.classList.add('hover');
};
TreeDropTarget.prototype._hideHoverIndication = function() {
this._targetElem && this._targetElem.classList.remove('hover');
};
TreeDropTarget.prototype._getTargetElem = function(avatar, event) {
var target = avatar.getTargetElem();
if (target.tagName != 'SPAN') {
return;
}
// проверить, может быть перенос узла внутрь самого себя или в себя?
var elemToMove = avatar.getDragInfo(event).dragZoneElem.parentNode;
var elem = target;
while (elem) {
if (elem == elemToMove) return; // попытка перенести родителя в потомка
elem = elem.parentNode;
}
return target;
};
TreeDropTarget.prototype.onDragEnd = function(avatar, event) {
if (!this._targetElem) {
// перенос закончился вне подходящей точки приземления
avatar.onDragCancel();
return;
}
this._hideHoverIndication();
// получить информацию об объекте переноса
var avatarInfo = avatar.getDragInfo(event);
avatar.onDragEnd(); // аватар больше не нужен, перенос успешен
// вставить элемент в детей в отсортированном порядке
var elemToMove = avatarInfo.dragZoneElem.parentNode; // <LI>
var title = avatarInfo.dragZoneElem.innerHTML; // переносимый заголовок
// получить контейнер для узлов дерева, соответствующий точке приземления
var ul = this._targetElem.parentNode.getElementsByTagName('UL')[0];
if (!ul) { // нет детей, создадим контейнер
ul = document.createElement('UL');
this._targetElem.parentNode.appendChild(ul);
}
// вставить новый узел в нужное место среди потомков, в алфавитном порядке
var li = null;
for (var i = 0; i < ul.children.length; i++) {
li = ul.children[i];
var childTitle = li.children[0].innerHTML;
if (childTitle > title) {
break;
}
li = null;
}
ul.insertBefore(elemToMove, li);
this._targetElem = null;
};
Итого
Реализация Drag’n’Drop оказалась отличным способом применить ООП в JavaScript.
Исходный код примера целиком находится в песочнице.
-
Синглтон
dragManager
и классыDrag*
задают общий фреймворк. От них наследуются конкретные объекты. Для создания новых зон достаточно унаследовать стандартные классы и переопределить их. -
Мини-фреймворк для Drag’n’Drop, который здесь представлен, является переписанным и обновлённым вариантом реальной библиотеки, на основе которой было создано много успешных скриптов переноса.
В зависимости от ваших потребностей, вы можете расширить его, добавить перенос нескольких объектов одновременно, поддержку событий и другие возможности.
-
На сегодняшний день в каждом серьёзном фреймворке есть библиотека для Drag’n’Drop. Она работает похожим образом, но сделать универсальный перенос – штука непростая. Зачастую он перегружен лишней функциональностью, либо наоборот – недостаточно расширяем в нужных местах. Понимание, как это все может быть устроено, на примере этой статьи, может помочь в адаптации существующего кода под ваши потребности.