Будьте в курсе последних событий, подпишитесь на обновления сайта

Применяем ООП: Drag’n’Drop на классах

Применяем ООП: Drag’n’Drop на классах

Здравствуйте!  В этой статье  я продолжу тему  Мышь: Drag’n’Drop расширенные возможности. Она  будет посвящена более гибкой и расширяемой реализации переноса объектов.

Я бы советовал вам почитать сперва этот материал  а уж  затем переходить  к этому.

В сложных программах Drag’n’Drop обладает рядом особенностей:

  1. Перетаскиваются элементы из зоны переноса  в зону-приемник. При этом сама зона не переносится.Например – есть 2 списка, нужен перенос элемента из одного в другой. В этом случае один список является зоной переноса, второй – зоной-приемником.На странице может быть несколько разных зон переноса и зон-приемников.
  2. Обработка завершения переноса может быть асинхронной, с уведомлением сервера.
  3. Должно быть легко добавить новый тип зоны переноса или зоны-приемника, а также расширить поведение существующей.
  4. Фреймворк для переноса должен быть легко расширяемым с учётом сложных сценариев.

Всё это вполне реализуемо. Но для этого фреймворк, описанный в статье Мышь: Drag’n’Drop расширенные возможности, нужно немного переделать и разделить на сущности.

drag-ndrop на классах

Основные сущности

Всего будет 4 сущности:

DragZone
Это Зона переноса. С нее начинается перенос. Она принимает нажатие мыши и генерирует аватар нужного типа.
DragAvatar
Переносимый объект. Предоставляет доступ к информации о том, что собственно переносится. Умеет двигать себя по экрану. В зависимости от вида переноса, может что-то делать с собой в конце, например, самоудалиться.
DropTarget
Зона-приемник, на которую можно положить. В процессе переноса аватара над ней умеет рисовать на себе предполагаемое «место посадки». Обрабатывает окончание переноса.
dragManager
Единый объект, который стоит над всеми ними, ставит обработчики mousedown/mousemove/mouseup и управляет всем процессом. В терминах ООП, это не класс, а объект-одиночка, поэтому он с маленькой буквы.

На макете страницы ниже возможен перенос студентов из левого списка – вправо, в одну из команд или в «корзину»:

drag-n-drop пример

Тут левый список является зоной переноса 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 – обработка событий мыши и координация всех остальных сущностей в процессе переноса.

Готовьтесь, дальше будет много кода с комментариями.

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

Если вызываемые в нём методы 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 – создать аватар и инициализировать его. В зависимости от места, где произошло нажатие, аватар получит соответствующий подэлемент зоны.

Читайте также  Делегирование событий в JavaScript

Метод для создания аватара _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. Она работает похожим образом, но сделать универсальный перенос – штука непростая. Зачастую он перегружен лишним функционалом, либо наоборот – недостаточно расширяем в нужных местах. Понимание, как это все может быть устроено, на примере этой статьи, может помочь в адаптации существующего кода под ваши потребности.

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

Поделиться

Об авторе

admin administrator

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

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