В свое время приходилось реализовывать кучу drag and drop'ов под самым разным соусом.
Эта статья представляет собой учебник-выжимку о том, как организовать drag'n'drop в javascript, начиная от основ и заканчивая готовым фреймворком.
Кроме того, почти все javascript-библиотеки реализуют drag and drop так, как написано (в статье дано несколько разных вариантов, не факт что ваш фреймворк использует лучший). Зная, что и как, вы сможете поправить и адаптировать существующую библиотеку под себя.
Drag'n'drop в свое время был замечательным открытием в области интерфейсов, которое позволило упростить большое количество операций.
Одно из самых очевидных применений drag'n'drop - переупорядочение данных. Это могут быть блоки, элементы списка, и вообще - любые DOM-элементы и их наборы.
Перенос мышкой может заменить целую последовательность кликов. И, самое главное, он упрощает внешний вид интерфейса: функции, реализуемые через drag'n'drop, в ином случае потребовали бы дополнительных полей, виджетов и т.п.
Организовать перенос элементов по странице - довольно просто. Для этого нужно:
mouseDown отследить клик на переносимом элементеmouseMove передвигать переносимый элемент по странице.mouseUp - остановить перенос элемента и произвести все действия, связанные с окончанием drag and drop.При обработке событий, связанных с мышью, нужен кроссбраузерный способ получения координат курсора из события в обработчике. Кроме того, необходимо знать нажатую кнопку мыши.
Для этого будем использовать свойства which и pageX/pageY, полное описание и механизмы кросс-браузерной реализации которых есть в статье по свойствам объекта событие.
whichpageX/pageYКроссбраузерно ставить эти свойства на объект будет функция fixEvent (по статье свойства объекта событие):
function fixEvent(e) {
// получить объект событие для IE
e = e || window.event
// добавить pageX/pageY для IE
if ( e.pageX == null && e.clientX != null ) {
var html = document.documentElement
var body = document.body
e.pageX = e.clientX + (html && html.scrollLeft || body && body.scrollLeft || 0) - (html.clientLeft || 0)
e.pageY = e.clientY + (html && html.scrollTop || body && body.scrollTop || 0) - (html.clientTop || 0)
}
// добавить which для IE
if (!e.which && e.button) {
e.which = e.button & 1 ? 1 : ( e.button & 2 ? 3 : ( e.button & 4 ? 2 : 0 ) )
}
return e
}
В этом коде e.which проходит кросс-браузерную обработку, чтобы корректно отражать нажатую кнопку мыши. Вы можете подробно прочитать об этом в статье Свойства объекта событие.
На демке ниже обработчик mouseMove отслеживает координаты курсора мыши относительно левого-верхнего угла страницы, используя кроссбраузерную обертку fixEvent.
document.onmousemove = mouseMove
function mouseMove(event){
event = fixEvent(event)
document.getElementById('mouseX').value = event.pageX
document.getElementById('mouseY').value = event.pageY
}
Координата X:
Координата Y:
Чтобы начать перенос элемента, мы должны отловить нажатие кнопки мыши на объекте.
Для этого нам пригодится событие mousedown. Повесим обработчик на те элементы, которые хотим сделать доступными для переноса.
Пока этот обработчик будет запоминать объект в глобальной переменной dragObject
element.onmousedown = function(e){
// запомнить переносимый объект
// в переменной dragObject
dragObject = this
// остановить обработку события
return false
}
Остановить обработку события return false очень важно - иначе браузер может запустить свои механизмы перетаскивания элементов и все нам поломать.
В случае с отпусканием кнопки мыши все проще - объект мы уже знаем, так что можно повесить один обработчик onmouseup на document.
document.onmouseup = function() {
// опустить переносимый объект
dragObject = null
}
При нажатии на элемент он запоминается и выделяется.
Выделение(запоминание) действует на все время, когда нажата кнопка мыши, в том числе при перемещении курсора.
Остается добавить визуальное перемещение элемента - и drag and drop заработает.
onmousedownИногда бывает, что объектов, которые могут быть перенесены, много. Например, это ячейка таблицы или длинный список, или дерево статей с массой узлов.
Тогда время инициализации можно сильно сократить, если назначать обработчик onmousedown не на каждый объект переноса, а на контейнер. И уже в самом обработчике по event.target определять, где произошел клик.
Перед дальнейшим развитием проведем реорганизацию кода.
Используем способ описания объекта без new (описан здесь как фабрика объектов), чтобы объявить объект dragMaster, предоставляющий необходимый функционал и отслеживающий перенос.
var dragMaster = (function() {
// private методы и свойства
var dragObject
function mouseDown(e) {
клик на переносимом элементе: начать перенос
}
function mouseMove(e){
if (dragObject) {
отобразить перенос объекта
}
}
function mouseUp(e){
if (dragObject) {
конец переноса
}
}
// public методы и свойства
return {
init: function() {
// инициализовать контроллер
document.onmousemove = mouseMove
document.onmouseup = mouseUp
},
makeDraggable: function(element){
// сделать элемент переносимым
element.onmousedown = mouseDown
}
}
}())
При таком способе задания объекта dragMaster получает публичные свойства (например, makeDraggable), которые имеют доступ к приватным переменным и методам mouse*, dragObject, так как являются вложенными функциями.
Полученный код:
dragObject.Последний пункт очень важен, так как обработчики mouseMove, mouseUp вызываются при каждом передвижении мыши и поднятии кнопки соответственно. Если mouseMove будет работать медленно, то передвижение курсора станет рваным, заметно тормозным.
На это натыкались многие писатели drag'n'drop приложений. Мы же будем изначально закладывать производительность во все критические участки кода.
Для того, чтобы перенести элемент, ему нужно поставить значение CSS-свойства position в absolute. Тогда он будет позиционироваться относительно верхнего-левого угла документа (точнее говоря, относительно ближайшего родителя, у которого position - relative/absolute, но у нас таких нет), и установка CSS-свойств left и top в координаты курсора мыши поместит левый-верхний угол элемента непосредственно под указатель.
Для перемещения элемента нам достаточно всего-лишь обновлять значения left/top при каждом движении мыши mousemove!
Посетитель обычно кликает не в левый-верхний угол, а куда угодно на элементе.
Поэтому чтобы элемент не прилипал к курсору верхним-левым углом, к позиции элемента необходимо добавить смещение мыши на момент клика.
На рисунке ниже mouseX/mouseY - координаты курсора мыши, а positionX/positionY - координаты верхнего-левого угла элемента, которые легко получить из DOM:

Отсюда легко получаем смещение курсора мыши:
mouseOffsetX = mouseX - positionX mouseOffsetY = mouseY - positionY
Это изначальное смещение мы запоминаем при клике, прибавляем его при начале движения и сохраняем в дальнейшем.
Тогда позиция элемента относительно курсора мыши будет все время одной и той же.
var mouseOffset
element.onmousedown = function(e) {
e = fixEvent(e)
...
var pos = getPosition(element)
mouseOffset= {
x: e.pageX - pos.x,
y: e.pageY - pos.y
}
...
}
document.onmousemove = function(e) {
e = fixEvent(e)
...
element.style.left = e.pageX - mouseOffset.x + 'px'
element.style.top = e.pageY - mouseOffset.y + 'px'
...
}
Код контроллера:
var dragMaster = (function() {
var dragObject
var mouseOffset
// получить сдвиг target относительно курсора мыши
function getMouseOffset(target, e) {
var docPos = getPosition(target)
return {x:e.pageX - docPos.x, y:e.pageY - docPos.y}
}
function mouseUp(){
dragObject = null
// очистить обработчики, т.к перенос закончен
document.onmousemove = null
document.onmouseup = null
document.ondragstart = null
document.body.onselectstart = null
}
function mouseMove(e){
e = fixEvent(e)
with(dragObject.style) {
position = 'absolute'
top = e.pageY - mouseOffset.y + 'px'
left = e.pageX - mouseOffset.x + 'px'
}
return false
}
function mouseDown(e) {
e = fixEvent(e)
if (e.which!=1) return
dragObject = this
// получить сдвиг элемента относительно курсора мыши
mouseOffset = getMouseOffset(this, e)
// эти обработчики отслеживают процесс и окончание переноса
document.onmousemove = mouseMove
document.onmouseup = mouseUp
// отменить перенос и выделение текста при клике на тексте
document.ondragstart = function() { return false }
document.body.onselectstart = function() { return false }
return false
}
return {
makeDraggable: function(element){
element.onmousedown = mouseDown
}
}
}())
function getPosition(e){
var left = 0
var top = 0
while (e.offsetParent){
left += e.offsetLeft
top += e.offsetTop
e = e.offsetParent
}
left += e.offsetLeft
top += e.offsetTop
return {x:left, y:top}
}
В коде появилась новая функция getPosition(элемент) - она получает абсолютные координаты верхнего-правого угла элемента. Функция это стандартная и много где используемая.
Кроме того, при начале переноса останавливается выделение и перенос текста браузером:
document.ondragstart = function() { return false }
document.body.onselectstart = function() { return false }
Если этого не сделать, то движение курсора мыши при нажатой кнопке будет не только перемещать элемент, но и, например, выделять текст под собой (стандартная функция выделения текста на странице).
Например, переносимый объект очень сложен, и его передвижение целиком тормозит браузер и выглядит громоздко/неэстетично.
Сам элемент при этом скрывается display/visibility='none' или просто остается на месте, в зависимости от логики интерфейса.
Переносимый клон инициализуется в начале переноса и уничтожается в конце.
Когда иконка опущена, нам необходимо определить, куда.
В другое хранилище - переместить. В корзину - удалить, и т.п.
Существенная техническая проблема заключается в том, что событие mouseup сработает не на корзине, а на переносимом элементе, т.к. курсор мыши находится именно над ним.

Поэтому в событии будет информация об элементе непосредственно под курсором. На картинке выше event.target = сердечко, а корзина в объекте события event не присутствует.
Определить, что иконка опущена на корзину, можно, сравнив координаты корзины с коорданатами мыши на момент события.
В момент опускания на корзину выводится сообщение, перемещаемая иконка - в переменной dragObject, цель переноса (корзина, объект-акцептор) - в переменной currentDropTarget.
В коде контроллера: функции, тело которых заменено на "...", остались без изменения с прошлого примера.
var dragMaster = (function() {
var dragObject
var mouseOffset
var dropTargets = []
function mouseUp(e){
e = fixEvent(e)
for(var i=0; i<dropTargets.length; i++){
var targ = dropTargets[i]
var targPos = getPosition(targ)
var targWidth = parseInt(targ.offsetWidth)
var targHeight = parseInt(targ.offsetHeight)
if(
(e.pageX > targPos.x) &&
(e.pageX < (targPos.x + targWidth)) &&
(e.pageY > targPos.y) &&
(e.pageY < (targPos.y + targHeight))){
alert("перенесен объект dragObject на акцептор currentDropTarget")
}
}
dragObject = null
removeDocumentEventHandlers()
}
function mouseDown(e) {
e = fixEvent(e)
if (e.which!=1) return
dragObject = this
mouseOffset = getMouseOffset(this, e)
addDocumentEventHandlers()
return false
}
function removeDocumentEventHandlers() {
document.onmousemove = null
document.onmouseup = null
document.ondragstart = null
document.body.onselectstart = null
}
function addDocumentEventHandlers() {
document.onmousemove = mouseMove
document.onmouseup = mouseUp
document.ondragstart = function() { return false }
document.body.onselectstart = function() { return false }
}
function getMouseOffset(target, e) {...}
function mouseMove(e) {...}
return {
makeDraggable: function(element){...},
addDropTarget: function(dropTarget){
dropTargets.push(dropTarget)
}
}
}())
var dragMaster2 = (function() {
var dragObject
var mouseOffset
var dropTargets = []
function mouseUp(e){
e = fixEvent(e)
for(var i=0; i<dropTargets.length; i++){
var targ = dropTargets[i]
var targPos = getPosition(targ)
var targWidth = parseInt(targ.offsetWidth)
var targHeight = parseInt(targ.offsetHeight)
if(
(e.pageX > targPos.x) &&
(e.pageX < (targPos.x + targWidth)) &&
(e.pageY > targPos.y) &&
(e.pageY < (targPos.y + targHeight))){
alert("dragObject was dropped onto currentDropTarget!")
}
}
dragObject = null
removeDocumentEventHandlers()
}
function removeDocumentEventHandlers() {
document.onmousemove = null
document.onmouseup = null
document.ondragstart = null
document.body.onselectstart = null
}
function getMouseOffset(target, e) {
var docPos = getPosition(target)
return {x:e.pageX - docPos.x, y:e.pageY - docPos.y}
}
function mouseMove(e){
e = fixEvent(e)
with(dragObject.style) {
position = 'absolute'
top = e.pageY - mouseOffset.y + 'px'
left = e.pageX - mouseOffset.x + 'px'
}
return false
}
function mouseDown(e) {
e = fixEvent(e)
if (e.which!=1) return
dragObject = this
mouseOffset = getMouseOffset(this, e)
addDocumentEventHandlers()
return false
}
function addDocumentEventHandlers() {
document.onmousemove = mouseMove
document.onmouseup = mouseUp
// отменить перенос и выделение текста при клике на тексте
document.ondragstart = function() { return false }
document.body.onselectstart = function() { return false }
}
return {
makeDraggable: function(element){
element.onmousedown = mouseDown
},
addDropTarget: function(dropTarget){
dropTargets.push(dropTarget)
}
}
}())
function getPosition(e){
var left = 0;
var top = 0;
while (e.offsetParent){
left += e.offsetLeft;
top += e.offsetTop;
e = e.offsetParent;
}
left += e.offsetLeft;
top += e.offsetTop;
return {x:left, y:top};
}
Основных изменений всего три.
Добавлен массив dropTargets и функция addDropTarget, которая добавляет в него элементы, на которые можно дропать.
Измененный обработчик mouseUp теперь проходит в цикле по возможным таким объектам и проверяет, не находится ли курсор внутри ограничивающего объект прямоугольника.
Если да, то демка всего лишь выводит сообщение. Реально приложение, конечно, может сделать более сложные действия.
Кроме того, установка и удаление обработчиков событий для document выделены в отдельные функции - просто в целях лучшей читаемости.
В удобном интерфейсе мы, скорее всего, захотим как-то показывать посетителю, над каким объектом он сейчас находится.
Единственно место, где это можно сделать - обработчик mouseMove. Сама проверка, над чем курсор сейчас находится, полностью аналогична mouseUp.
Однако, так как mouseMove выполняется при каждом передвижении мыши, его надо максимально оптимизировать.
Функция getPosition - довольно медленная: она работает с DOM, и ей надо пройти по всей цепочке offsetParent. Выполнять ее каждый раз при движении мыши для поиска текущего акцептора - все равно что нажать на большой-большой тормоз.
Стандартным выходом в такой ситуации является кеширование координат акцепторов, а точнее - их ограничивающих прямоугольников, так чтобы код mouseMove был максимально прост.
Код уже стал довольно длинным, поэтому в листинге ниже повторяющиеся фрагменты заменены на троеточие "..."
var dragMaster = (function() {
var dragObject
var mouseOffset
var dropTargets = []
/* кеш прямоугольников границ акцепторов */
var dropTargetRectangles
/* текущий акцептор, над которым объект в данный момент */
var currentDropTarget
function cacheDropTargetRectangles() {
dropTargetRectangles = /* сделать кеш прямоугольников */
}
function mouseDown(e) {
e = fixEvent(e)
if (e.which!=1) return
/* начать перенос */
dragObject = this
mouseOffset = getMouseOffset(this, e)
/* закешировать прямоугольники при начале переноса */
cacheDropTargetRectangles()
addDocumentEventHandlers()
return false
}
function getCurrentTarget(e) {
var dropTarget = /* взять из кеша прямоугольник, в котором мышь */
return dropTarget /* null, если мы не над акцепторами */
}
function mouseMove(e){
/* визуально показать перенос объекта */
with(dragObject.style) {
position = 'absolute'
top = e.pageY - mouseOffset.y + 'px'
left = e.pageX - mouseOffset.x + 'px'
}
/* newTarget = над каким акцептором сейчас объект */
var newTarget = getCurrentTarget(e)
/* если ушли со старого акцептора */
if (currentDropTarget && currentDropTarget != newTarget) {
/* убрать выделение currentDropTarget */
}
/* пришли на новый акцептор (возможно null) */
currentDropTarget = newTarget
/* если новый акцептор существует (не null) */
if (newTarget) {
/* выделить newTarget */
}
return false;
}
function mouseUp(ev){
if (currentDropTarget) {
alert("перенесен объект dragObject на акцептор currentDropTarget")
/* убрать выделение с currentDropTarget */
}
/* конец операции переноса */
dragObject = null
removeDocumentEventHandlers()
}
function getMouseOffset(target, e) {...}
function addDocumentEventHandlers() {...}
function removeDocumentEventHandlers() {...}
return {
...
}
}())
Полностью рабочий вариант с кешем и т.п. - здесь:
var dragMaster = (function() {
var dragObject
var mouseOffset
var dropTargets = []
var dropTargetRectangles
var currentDropTarget
function cacheDropTargetRectangles() {
dropTargetRectangles = []
for(var i=0; i<dropTargets.length; i++){
var targ = dropTargets[i];
var targPos = getPosition(targ);
var targWidth = parseInt(targ.offsetWidth);
var targHeight = parseInt(targ.offsetHeight);
dropTargetRectangles.push({
xmin: targPos.x,
xmax: targPos.x + targWidth,
ymin: targPos.y,
ymax: targPos.y + targHeight,
dropTarget: targ
})
}
}
function mouseUp(ev){
if (currentDropTarget) {
alert("перенесен объект dragObject на акцептор currentDropTarget")
showRollOff(currentDropTarget)
}
dragObject = null
removeDocumentEventHandlers()
}
function getCurrentTarget(e) {
for(var i=0; i<dropTargetRectangles.length; i++){
var rect = dropTargetRectangles[i];
if(
(e.pageX > rect.xmin) &&
(e.pageX < rect.xmax) &&
(e.pageY > rect.ymin) &&
(e.pageY < rect.ymax)){
return rect.dropTarget
}
}
return null
}
function mouseMove(e){
e = fixEvent(e)
with(dragObject.style) {
position = 'absolute'
top = e.pageY - mouseOffset.y + 'px'
left = e.pageX - mouseOffset.x + 'px'
}
var newTarget = getCurrentTarget(e)
if (currentDropTarget && currentDropTarget != newTarget) {
showRollOff(currentDropTarget)
}
currentDropTarget = newTarget
if (newTarget) {
showRollOn(newTarget)
}
return false;
}
function showRollOn(elem) {
elem.className = 'uponMe'
}
function showRollOff(elem) {
elem.className = ''
}
function mouseDown(e) {
e = fixEvent(e)
if (e.which!=1) return
dragObject = this
mouseOffset = getMouseOffset(this, e)
cacheDropTargetRectangles()
addDocumentEventHandlers()
return false
}
function getMouseOffset(target, e) {...}
function addDocumentEventHandlers() {...}
function removeDocumentEventHandlers() {...}
return {
...
}
}())
var dragMaster3 = (function() {
var dragObject
var mouseOffset
var dropTargets = []
var dropTargetRectangles
var currentDropTarget
function cacheDropTargetRectangles() {
dropTargetRectangles = []
for(var i=0; i<dropTargets.length; i++){
var targ = dropTargets[i];
var targPos = getPosition(targ);
var targWidth = parseInt(targ.offsetWidth);
var targHeight = parseInt(targ.offsetHeight);
dropTargetRectangles.push({
xmin: targPos.x,
xmax: targPos.x + targWidth,
ymin: targPos.y,
ymax: targPos.y + targHeight,
dropTarget: targ
})
}
}
function mouseUp(ev){
if (currentDropTarget) {
alert("droped dragObject into curTarget")
showRollOff(currentDropTarget)
}
dragObject = null
removeDocumentEventHandlers()
}
function removeDocumentEventHandlers() {
document.onmousemove = null
document.onmouseup = null
document.ondragstart = null
document.body.onselectstart = null
}
function getMouseOffset(target, e) {
var docPos = getPosition(target)
return {x:e.pageX - docPos.x, y:e.pageY - docPos.y}
}
function getCurrentTarget(e) {
for(var i=0; i<dropTargetRectangles.length; i++){
var rect = dropTargetRectangles[i];
if(
(e.pageX > rect.xmin) &&
(e.pageX < rect.xmax) &&
(e.pageY > rect.ymin) &&
(e.pageY < rect.ymax)){
return rect.dropTarget
}
}
return null
}
function mouseMove(e){
e = fixEvent(e)
with(dragObject.style) {
position = 'absolute'
top = e.pageY - mouseOffset.y + 'px'
left = e.pageX - mouseOffset.x + 'px'
}
var newTarget = getCurrentTarget(e)
if (currentDropTarget && currentDropTarget != newTarget) {
showRollOff(currentDropTarget)
}
currentDropTarget = newTarget
if (newTarget) {
showRollOn(newTarget)
}
return false;
}
function showRollOn(elem) {
elem.className = 'uponMe'
}
function showRollOff(elem) {
elem.className = ''
}
function mouseDown(e) {
e = fixEvent(e)
if (e.which!=1) return
dragObject = this
mouseOffset = getMouseOffset(this, e)
cacheDropTargetRectangles()
addDocumentEventHandlers()
return false
}
function addDocumentEventHandlers() {
document.onmousemove = mouseMove
document.onmouseup = mouseUp
// отменить перенос и выделение текста при клике на тексте
document.ondragstart = function() { return false }
document.body.onselectstart = function() { return false }
}
return {
makeDraggable: function(element){
element.onmousedown = mouseDown
},
addDropTarget: function(dropTarget){
dropTargets.push(dropTarget)
}
}
}())
function getPosition(e){
var left = 0;
var top = 0;
while (e.offsetParent){
left += e.offsetLeft;
top += e.offsetTop;
e = e.offsetParent;
}
left += e.offsetLeft;
top += e.offsetTop;
return {x:left, y:top};
}
Следуя общему принципу отделения мух от котлет - лучше отделить простой клик на объекте от начала drag and drop.
Еще одна причина - дорогая инициализация drag & drop: нужно прокешировать все возможные акцепторы. Совершенно не обязательно это делать на mousedown, если имеем простой клик.
Как отделить? Очень просто:
mousedown запомнить координаты и объект, но пока не начинать переносmouseup - это был всего лишь клик, сбросить координатыmousemove проверить: если есть запомненные координаты и курсор отошел от них хотя бы на 2 пикселя - начать переносВ коде этой демки стоит расстояние не 2, а 25 пикселей, в целях наглядности происходящего.
До перемещения курсора на 25 пикселей вверх или вниз перенос не начнется.
Код демо:
var dragMaster = (function() {
...
var mouseDownAt
function mouseDown(e) {
e = fixEvent(e)
if (e.which!=1) return
mouseDownAt = { x: e.pageX, y: e.pageY, dragObject: this }
addDocumentEventHandlers()
return false
}
function mouseMove(e){
e = fixEvent(e)
if (mouseDownAt) {
if (Math.abs(mouseDownAt.x-e.pageX)<25 && Math.abs(mouseDownAt.y-e.pageY)<25) {
// слишком близко, возможно это клик
return
}
// курсор нажатой мыши отвели далеко - начинаем перенос
dragObject = mouseDownAt.dragObject
mouseOffset = getMouseOffset(dragObject, mouseDownAt.x, mouseDownAt.y)
cacheDropTargetRectangles()
// запомненные координаты нам больше не нужны
mouseDownAt = null
}
showDrag(e) // показать перенос
return false;
}
function mouseUp(ev){
if (!dragObject) {
// ничего не начали нести, был просто клик
mouseDownAt = null
} else {
// чего-то несем - обрабатываем конец переноса
if (currentDropTarget) {
alert("перенесен объект dragObject на акцептор currentDropTarget")
showRollOff(currentDropTarget)
}
dragObject = null
}
// (возможный) drag and drop завершен
removeDocumentEventHandlers()
}
function showDrag(e) {
// перенести объект
with(dragObject.style) {
position = 'absolute'
top = e.pageY - mouseOffset.y + 'px'
left = e.pageX - mouseOffset.x + 'px'
}
// подсветить акцептор
var newTarget = getCurrentTarget(e)
if (currentDropTarget && currentDropTarget != newTarget) {
showRollOff(currentDropTarget)
}
currentDropTarget = newTarget
if (newTarget) {
showRollOn(newTarget)
}
}
function getMouseOffset(target, x, y) {
// для удобства поменяли синтаксис: x/y вместо event
var docPos = getPosition(target)
return {x:x - docPos.x, y:y - docPos.y}
}
function cacheDropTargetRectangles() {...}
function removeDocumentEventHandlers() {...}
function getCurrentTarget(e) {...}
function showRollOn(elem) {...}
function showRollOff(elem) {...}
function addDocumentEventHandlers() {...}
return {
...
}
}())
var dropMove2 = (function() {
var dragObject
var mouseOffset
var mouseDownAt
var dropTargets = []
var dropTargetRectangles
var currentDropTarget
function cacheDropTargetRectangles() {
dropTargetRectangles = []
for(var i=0; i<dropTargets.length; i++){
var targ = dropTargets[i];
var targPos = getPosition(targ);
var targWidth = parseInt(targ.offsetWidth);
var targHeight = parseInt(targ.offsetHeight);
dropTargetRectangles.push({
xmin: targPos.x,
xmax: targPos.x + targWidth,
ymin: targPos.y,
ymax: targPos.y + targHeight,
dropTarget: targ
})
}
}
function mouseUp(ev){
if (!dragObject) {
mouseDownAt = null
} else {
if (currentDropTarget) {
alert("перенесен объект dragObject на акцептор currentDropTarget")
showRollOff(currentDropTarget)
}
dragObject = null
}
removeDocumentEventHandlers()
}
function removeDocumentEventHandlers() {
document.onmousemove = null
document.onmouseup = null
document.ondragstart = null
document.body.onselectstart = null
}
function getMouseOffset(target, x, y) {
var docPos = getPosition(target)
return {x:x - docPos.x, y:y - docPos.y}
}
function getCurrentTarget(e) {
for(var i=0; i<dropTargetRectangles.length; i++){
var rect = dropTargetRectangles[i];
if(
(e.pageX > rect.xmin) &&
(e.pageX < rect.xmax) &&
(e.pageY > rect.ymin) &&
(e.pageY < rect.ymax)){
return rect.dropTarget
}
}
return null
}
function mouseMove(e){
e = fixEvent(e)
if (mouseDownAt) {
if (Math.abs(mouseDownAt.x-e.pageX)<25 && Math.abs(mouseDownAt.y-e.pageY)<25) {
return
}
dragObject = mouseDownAt.dragObject
mouseOffset = getMouseOffset(dragObject, mouseDownAt.x, mouseDownAt.y)
cacheDropTargetRectangles()
mouseDownAt = null
}
showDrag(e)
return false;
}
function showDrag(e) {
with(dragObject.style) {
position = 'absolute'
top = e.pageY - mouseOffset.y + 'px'
left = e.pageX - mouseOffset.x + 'px'
}
var newTarget = getCurrentTarget(e)
if (currentDropTarget && currentDropTarget != newTarget) {
showRollOff(currentDropTarget)
}
currentDropTarget = newTarget
if (newTarget) {
showRollOn(newTarget)
}
}
function showRollOn(elem) {
elem.className = 'uponMe'
}
function showRollOff(elem) {
elem.className = ''
}
function mouseDown(e) {
e = fixEvent(e)
if (e.which!=1) return
mouseDownAt = { x: e.pageX, y: e.pageY, dragObject: this }
addDocumentEventHandlers()
return false
}
function addDocumentEventHandlers() {
document.onmousemove = mouseMove
document.onmouseup = mouseUp
// отменить перенос и выделение текста при клике на тексте
document.ondragstart = function() { return false }
document.body.onselectstart = function() { return false }
}
return {
makeDraggable: function(element){
element.onmousedown = mouseDown
},
addDropTarget: function(dropTarget){
dropTargets.push(dropTarget)
}
}
}())
function getPosition(e){
var left = 0;
var top = 0;
while (e.offsetParent){
left += e.offsetLeft;
top += e.offsetTop;
e = e.offsetParent;
}
left += e.offsetLeft;
top += e.offsetTop;
return {x:left, y:top};
}
Бывает, что возможных акцепторов очень много. Тогда код, кеширующий прямоугольники при начале переноса, будет тормозить.
Визуально это проявляется как задержка от клика на объекте до его фактического переноса - потому что долго, с 100% поеданием одного ядра CPU обрабатывается mousedown.
Речь тут идет о 100 акцепторах или больше - например, при переносе между длинными списками или деревьями. Хотя какие-то тормоза могут быть заметны и от 50.
Принципиальных решений здесь два.
document.elementFromPoint(x,y)Этот малоизвестный метод работает во всех браузерах и возвращает элемент по координатам на странице.
Firefox/IE используют для этого clientX/Y, а Opera, Chrome и Safari - pageX/Y.
Возвращенный элемент является самым глубоко вложенным на точке с координатами (x,y). Это может быть текстовый узел в том числе.
Следуя по цепочке родителей, легко найти нужного акцептора.
На время вызова elementFromPoint необходимо спрятать переносимый элемент, чтобы он не закрывал акцептора.
Псевдокод будет выглядеть так:
function getCurrentTarget(e) {
dragObject.style.display = 'none' // спрятать
if (navigator.userAgent.match('MSIE') || navigator.userAgent.match('Gecko')) {
// IE || FF
var elem = document.elementFromPoint(e.clientX,e.clientY)
} else {
var elem = document.elementFromPoint(e.pageX,e.pageY)
}
dragObject.style.display = '' // показать побыстрее
while (elem!= null) {
if (elem является акцептором) {
return elem
}
elem = elem.parentNode
}
// не нашли
return null
}
Если структура объекта переноса нам известна - возможно, кешировать каждый элемент ни к чему?
Например, мы переносим объект в список акцепторов:
<ul class="articles-list"> <li>...</li> <li>...</li> <li>...</li> </ul>
И мы знаем, что каждый элемент имеет фиксированную высоту и отступы:
.articles-list li {
height: 20px;
margin: 0px;
padding: 0px;
}
В таком случае, можно вычислить номер LI, разделив общую высоту контейнера на высоту акцептора.
function getCurrentTarget(e) {
var rect = /* ограничивающий прямоугольник для UL */
// (ulX, ulY) - координаты относительно верхнего-левого угла контейнера
var ulX= e.pageX - rect.xmin
var ulY = e.pageY - rect.ymin
if ( ulX < 0 || ulX > rect.xmax-rect.xmin || ulY < 0 || ulY > rect.ymax-rect.ymin) {
/* событие за границами списка */
return null
}
/* по 20px на каждого ребенка-акцептора */
var childNum = ulY / 20
return rect.dropTarget.childNodes[childNum]
}
Конечно, такая оптимизация возможна не всегда и зависит от конкретной задачи.
В ряде задач допустимо не сохранять переносимый элемент под курсором, а передвинуть его на несколько пикселей в сторону, то mouseUp и mouseMove будут срабатывать уже на акцепторе, который станет возможным получить из event.target.
Да, получится не так красиво, но это может быть единственным выходом.
Можно переносить не сам объект, а его "аватар", схематическое изображение.
Это может быть полезно:
Код dragMaster'а на текущий момент сочетает весь функционал по отслеживанию переноса, отображению и опусканию на акцептор.
Целесообразно его разделить на три компоненты: переносимые объекты, цели переноса (акцепторы) и менеджер, который следит, что куда переносится.
DragObjectОбъект DragObject - обобщенный переносимый объект, который привязывается к DOM-элементу. Он представлен на следующем листинге.
function DragObject(element) {
element.dragObject = this
dragMaster.makeDraggable(element)
var rememberPosition
var mouseOffset
this.onDragStart = function(offset) {
var s = element.style
rememberPosition = {top: s.top, left: s.left, position: s.position}
s.position = 'absolute'
mouseOffset = offset
}
this.hide = function() {
element.style.display = 'none'
}
this.show = function() {
element.style.display = ''
}
this.onDragMove = function(x, y) {
element.style.top = y - mouseOffset.y +'px'
element.style.left = x - mouseOffset.x +'px'
}
this.onDragSuccess = function(dropTarget) { }
this.onDragFail = function() {
var s = element.style
s.top = rememberPosition.top
s.left = rememberPosition.left
s.position = rememberPosition.position
}
this.toString = function() {
return element.id
}
}
Использование:
new DragObject(element)
Достаточно одного вызова new, т.к функция DragObject добавляет конструируемый объект к element в свойство element.dragObject:
... element.dragObject = this ...
Методы:
rememberPosition и сдвиг курсора мыши от левого-верхнего угла объекта в mouseOffset.
В другой реализации может показывать перенос как-то по-другому, например создавать "переносимый клон" объекта.
Вообще говоря, можно делать это с анимацией, показывая как объект "перелетает" на старое место.
DropTaget, поэтому эта функция пустая.DropTargetDropTarget - обобщенный объект-акцептор, потенциальная цель переноса. Может быть большим контейнером или маленьким элементом - не важно.
Как показано дальше, поддерживаются вложенные DropTarget: объект будет положен туда, куда следует, вне зависимости от степени вложенности.
function DropTarget(element) {
element.dropTarget = this
this.canAccept = function(dragObject) {
return true
}
this.accept = function(dragObject) {
this.onLeave()
dragObject.hide()
alert("Акцептор '"+this+"': принял объект '"+dragObject+"'")
}
this.onLeave = function() {
element.className = ''
}
this.onEnter = function() {
element.className = 'uponMe'
}
this.toString = function() {
return element.id
}
}
Методов у DropTarget поменьше, чем у DragObject. Еще бы, акцептору ведь не нужно анимировать собственный перенос.
DropTarget, dragMaster спросит у акцептора, может ли он принять dragObject. Если нет - dragMaster проигнорирует этот акцептор.
В текущей реализации всегда возвращает true, то есть положить можно. Вообще говоря, может проверять класс переносимого объекта:
this.canAccept = function(dragObject) {
// могу принять только объекты типа TreeNodeDragObject
return dragObject instanceof TreeNodeDragObject
}
В конкретном приложении стоит посмотреть особо, какую часть логики конца переноса стоит поместить DragObject#onDragSuccess, а какую - в DropTarget#accept. Как правило, основную логику переноса удобно сосредоточить у DropTarget.
canAccept) переносимый объект.dragMasterНу и, наконец, похудевший и лишенный большинства обязанностей dragMaster.
var dragMaster = (function() {
var dragObject
var mouseDownAt
var currentDropTarget
function mouseDown(e) {
e = fixEvent(e)
if (e.which!=1) return
mouseDownAt = { x: e.pageX, y: e.pageY, element: this }
addDocumentEventHandlers()
return false
}
function mouseMove(e){
e = fixEvent(e)
// (1)
if (mouseDownAt) {
if (Math.abs(mouseDownAt.x-e.pageX)<5 && Math.abs(mouseDownAt.y-e.pageY)<5) {
return false
}
// Начать перенос
var elem = mouseDownAt.element
// текущий объект для переноса
dragObject = elem.dragObject
// запомнить, с каких относительных координат начался перенос
var mouseOffset = getMouseOffset(elem, mouseDownAt.x, mouseDownAt.y)
mouseDownAt = null // запомненное значение больше не нужно, сдвиг уже вычислен
dragObject.onDragStart(mouseOffset) // начали
}
// (2)
dragObject.onDragMove(e.pageX, e.pageY)
// (3)
var newTarget = getCurrentTarget(e)
// (4)
if (currentDropTarget != newTarget) {
if (currentDropTarget) {
currentDropTarget.onLeave()
}
if (newTarget) {
newTarget.onEnter()
}
currentDropTarget = newTarget
}
// (5)
return false
}
function mouseUp(){
if (!dragObject) { // (1)
mouseDownAt = null
} else {
// (2)
if (currentDropTarget) {
currentDropTarget.accept(dragObject)
dragObject.onDragSuccess(currentDropTarget)
} else {
dragObject.onDragFail()
}
dragObject = null
}
// (3)
removeDocumentEventHandlers()
}
function getMouseOffset(target, x, y) {
var docPos = getOffset(target)
return {x:x - docPos.left, y:y - docPos.top}
}
function getCurrentTarget(e) {
// спрятать объект, получить элемент под ним - и тут же показать опять
if (navigator.userAgent.match('MSIE') || navigator.userAgent.match('Gecko')) {
var x=e.clientX, y=e.clientY
} else {
var x=e.pageX, y=e.pageY
}
// чтобы не было заметно мигание - максимально снизим время от hide до show
dragObject.hide()
var elem = document.elementFromPoint(x,y)
dragObject.show()
// найти самую вложенную dropTarget
while (elem) {
// которая может принять dragObject
if (elem.dropTarget && elem.dropTarget.canAccept(dragObject)) {
return elem.dropTarget
}
elem = elem.parentNode
}
// dropTarget не нашли
return null
}
function addDocumentEventHandlers() {
document.onmousemove = mouseMove
document.onmouseup = mouseUp
document.ondragstart = document.body.onselectstart = function() {return false}
}
function removeDocumentEventHandlers() {
document.onmousemove = document.onmouseup = document.ondragstart = document.body.onselectstart = null
}
return {
makeDraggable: function(element){
element.onmousedown = mouseDown
}
}
}())
mouseDownAt и добавляет остальные обработчики слежения за переносом addDocumentEventHandlers().document в момент mouseDown.
DragObject'у это сообщается вызовом onDragStart с передачей текущего сдвига курсора относительно левого-верхнего угла mouseOffset.
getCurrentTarget (см. выше в разделе "Оптимизация").false для блокирования действий браузера и всплывания события mouseMovemouseMove.
mouseDown, элемент не отнесли на 5 пикселей в сторону - это не drag'n'drop.Функция определения позиции элемента getPosition заменена на более точный вариант getOffset, описанный в статье про определение координат.
Скачать пакет из итогового фреймворка и демок можно здесь.
В комплекте - пара демок. На этой странице обе подключены в iframe'ах.
Открыть в отдельном окне: demo.html
В этой демке акцепторами являются два вложенных DIV'а.
Открыть в отдельном окне: nested.html
В процессе переноса акцепторы объекты могут сдвигаться, освобождая место.
Если вы используете кеш координат акцепторов, то при этом производится соответствующее обновление кеша, но вместо полного перевычисления обновляются только координаты сдвинувшихся объектов.
Например, при раздвижении списка вниз - увеличиваются Y-координаты всех сдвинувшихся LI.
При этом для удобного обновления кеш делается не массивом, а объектом с доступом по ID элемента, так чтобы можно было легко обновить именно нужные координаты.
Иногда, например, при смене позиции элемента в списке, объект переносится не на акцептор, а между акцепторами. Как правило, "между" - имеется в виду по высоте.
Для этого логику определения currentDropTarget нужно поменять. Возможно два варианта:
clientHeight: 25% - 50% - 25%, и определяется попадание координаты события на нужную часть.Кроме того, в дополнение к текущему акцептору currentDropTarget добавляется флаг, обозначающий, куда относительно акцептора происходит перенос.
Индикацию "переноса между" удобнее всего делать либо раздвижением элементов, либо показом полосы-индикатора border-top/border-bottom, как показано на рисунке ниже:
Обычно при переносе объекта куда-либо посетитель может просто отпустить его в любом месте.
При этом drag and drop фреймворк анимирует отмену переноса. Один из частых вариантов - скольжение объекта обратно к исходному месту, откуда его взяли.
Конечно, для этого исходное место необходимо запомнить.
Если перетаскивается аватарка(клон), то можно его и просто уничтожить.
Совершенно не факт, что любой объект можно перенести на любой аксептор.
Как правило, все с точностью наоборот.
Акцептор может быть недоступен по двум причинам:
При переносе над недоступным акцептором - getCurrentTarget просто возвращает null.
Иногда проверку прав и результата переноса необходимо делать на сервере. Как правило, такую проверку выполняют только при mouseUp, чтобы не нагружать сервер излишними запросами во время mouseMove.
Здесь используется два подхода
callback, содержащий информацию, откуда был перенесен объект.callback выводит ошибку и объект возвращается туда, откуда был перенесен.Вы узнали основные принципы и особенности реализации drag and drop в яваскрипт.
Рассмотрели ряд оптимизаций и различные организации переноса на уровне UI.
Все современные javascript-библиотеки используют описанную схему и какие-то (не все) из оптимизаций.
Пакет из итогового фреймворка и демок доступен здесь.