7 июня 2022 г.

Применяем ООП: Drag'n'Drop++

Материал на этой странице устарел, поэтому скрыт из оглавления сайта.

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

Рекомендуется прочитать указанную главу перед тем, как двигаться дальше.

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

  1. Перетаскиваются элементы из зоны переноса dragZone в зону-цель dropTarget. При этом сама зона не переносится.

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

    Возможно, что перенос осуществляется внутри одного и того же списка. При этом dragZone == dropTarget.

  2. На странице может быть несколько разных зон переноса и зон-целей.

  3. Обработка завершения переноса может быть асинхронной, с уведомлением сервера.

  4. Должно быть легко добавить новый тип зоны переноса или зоны-цели, а также расширить поведение существующей.

  5. Фреймворк для переноса должен быть расширяемым с учётом сложных сценариев.

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

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

Комментарии

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