Sortable v1.0: Новые возможности

    Привет хабр! В преддверии нового года хочу поделится своей радостью — выходом Sortable v1.0. Ровно год назад я представил на ваш суд мой маленький инструмент для сортировки списка при помощи drag’n’drop. Всё это время я скрупулезно собирал обратную связь, добавлял новые возможности и правил мелкие баги. Под катом я расскажу о новых возможностях, интеграции с AngularJS, Meteor и других нюансах.


    Введение


    Sortable — это минималистичный инструмент, для организации сортировки внутри списка и между списками. Библиотека не зависит от jQuery или других решений, использует Native Drag’n’Drop API, работает как на desktop, так и touch устройствах. Имеет простое API, легко интегрируется в проект и является отличной заменой jQueryUI / Sortable ;]

    И так, что же нового принес этот релиз:

    • Продвинутые группы (гибкая настройка перемещений и не только)
    • Анимация при перемещении
    • Умная прокрутка окна браузера и списка
    • Отключение сортировки (позволяет эмитировать draggable и droppable)
    • Методы для получения и изменения сортировки
    • Возможность фильтрации
    • Поддержка AngularJS и Meteor


    Продвинутые группы


    С самого начала библиотека имела возможность перемещения между группами, достаточно было задать им одинаковое имя и готово:
    // foo и bar — ссылки на HTMLElement
    Sortable.create(foo, { group: 'shared', });
    Sortable.create(bar, { group: 'shared' });
    http://jsbin.com/yexine/1/edit
    Пример работы (gif)
    image

    Со временем, стало очевидно, что этого недостаточно. Например, нельзя было сделать так, чтобы один список только отдавал, а другой только принимал элементы. Помимо этого, ключевым недостатком было отсутствие возможности как-то организовать взаимодействие нескольких групп. Нужно было выработать решение, которое бы позволило реализовать возникшие задачи и оставить возможность расширения на будущее, при этом не потерять текущий интерфейс.

    Теперь можно задать опцию `group` как объект со следующими свойствами:
    • name — название группы;
    • pull — возможность «вытаскивать» элементы при перемещении между списками, так же свойство может принимать значение `clone`;
    • put — возможность принять элемент из другой группы, либо массив разрешенных групп.

    Как это работает проще объяснить этом на примере:
    • У вас есть три списка «A», «B» и «C»;
    • Перетаскивать нужно из «A» и «B» в «C», между «A» и «B» перенос невозможен;
    • При перетаскивании из «A» на его месте должен появляться «клон».

    Чтобы показать сразу все возможности, я решу эту задачу двумя способами.
    Общая группа Несколько групп
    http://jsbin.com/yexine/2/edit http://jsbin.com/yexine/8/edit
    Sortable.create(listA, {
      group: {
        name: 'shared',
        pull: 'clone',
        put: false
      }
    });
    
    Sortable.create(listB, {
      group: {
        name: 'shared',
        put: false
      }
    });
    
    Sortable.create(listC, {
      group: 'shared'
    });
    Sortable.create(listA, {
      group: {
        name: 'A',
        pull: 'clone'
      }
    });
    
    Sortable.create(listB, {
      group: 'B'
    });
    
    Sortable.create(listC, {
      group: {
        put: ['A', 'B']
      }
    });

    Анимация


    Тут особо расписывать нечего, анимация сделана очень просто, при помощи CSS3 transition, включить её можно задав опцию `animation` в `ms`. Увы, у неё есть недостатки, которые пока не решены, но надеюсь в будущем найдется способ, как «дешево» их исправить.
    http://jsbin.com/yexine/4/edit
    Пример работы (gif)


    Умная прокрутка окна и списка


    Совсем недавно возникла задача: сделать прокрутку окна при достижении одного из краев. По идее, это должно было работать по умолчанию, т.к. используется Native Drag’n’Drop API, но на деле браузер крайне не охотно прокручивал окно. Так же оставалась проблема, если список находится в overflow. Поэтому подумав, получилось сделать умную прокрутку, которая сначала прокручивала список, если он в overflow и/или окно если мы достигли края браузера. Для более тонкой настройки введены три дополнительные опции:
    • scroll — включить авто-прокрутку;
    • scrollSensitivity — на сколько нужно приблизится к краю для активации прокрутки;
    • scrollSpeed — скорость прокрутки в `px`;

    Примеры:

    Отключение сортировки


    Да, именно так, это может показаться странным, но при помощи этого параметра можно отключить то, ради чего и создан данный инструмент ;] Например это можно использовать для имитации draggable и droppable: http://jsbin.com/xizeh/3/edit?html,js,output


    Методы для получения и изменения сортировки


    Так как эта библиотека появилась в результате исследования возможностей Drag’n’Drop API, то банальные методы получить порядок или изменить его просто не закладывались. Заглянув в API jQueryUI, обнаружил, что у них можно только получить порядок элементов, но изменить его нельзя, это не порядок ;] Чтобы решить все эти проблемы, было добавлено свойство `store`, которое принимает объект из двух параметров `get` и `set` для получения и сохранения сортировки, а так же два метода `toArray` и `sort`.

    Например, сохранение порядка через `localStorage` будет выглядеть следующим образом:
    Sortable.create(users, {
      store: {
        // Получение сортировки (вызывается при инициализации)
        get: function (sortable) {
          var order = localStorage.getItem(sortable.options.group);
          return order ? order.split('|') : [];
        },
    
        // Сохранение сортировки (вызывается каждый раз при её изменении)
        set: function (sortable) {
          var order = sortable.toArray();
          localStorage.setItem(sortable.options.group, order.join('|'));
        }
      }
    });

    http://jsbin.com/yexine/7/edit (измените порядок и обновите страницу)


    Возможность фильтрации


    Допустим вам нужно сделать сортируемый список с возможностью редактирования и удаления элемента. Раньше вам бы понадобилась самостоятельно навесить нужные обработчики. Теперь, решить подобную задачу можно средствами самой библиотеки, без дополнительных инструментов: http://jsbin.com/yexine/6/edit?html,js,output


    Поддержка AngularJS


    Angular всё больше завоевывает рынок и чтобы облегчить людям использование Sortable, было решено сделать директиву, для быстрой интеграции в проект. Посмотрев на аналоги, я увидел странность, все как один делают так:
    <ul ui-sortable="sortableOptions" ng-model="items">
        <li ng-repeat="item in items">{{ item }}</li>
    </ul>

    Зачем? Ведь это попросту copy-paste, да и если быть совсем честным, костыль. На мой взгляд, логичной и правильной будет следующая запись, без `ng-model`:
    <ul ng-sortable="sortableOptions">
        <li ng-repeat="item in items">{{ item }}</li>
    </ul>

    Интересующие нас данные уже содержит `ng-repeat`, чтобы их получить нам понадобиться функция `$parse` и небольшая хитрость. Хитрость заключается в том, что данные из `ng-repeat` можно получить только найдя спец. комментарий оставленный самим ангуляром:
    <ul ng-sortable="{ animation: 150 }">
           <!-- ngRepeat: item in items -->
           <!-- end ngRepeat: item in items -->
    </ul>

    Теперь мы можем создать метод для работы с данными связанными с `ng-repeat`:
    /**
     * Получить объект для работы с данными в ng-repeat
     * @param {HTMLElement}  el
     * @returns {object}
     */
    function getNgRepeat(el) {
      // Получаем текущий `scope` связанный в элементом
      var scope = angular.element(el).scope();
      
      // Находим нужный нам комментарий
      var ngRepeat = [].filter.call(el.childNodes, function (node) {
         return (
               (node.nodeType === 8) &&
               (node.nodeValue.indexOf('ngRepeat:') !== -1)
            );
      })[0];
    
      // Прасим название переменных элемента и массива
      ngRepeat = ngRepeat.nodeValue.match(/ngRepeat:\s*([^\s]+)\s+in\s+([^\s|]+)/);
    
      // Конвертируем названия переменных в `expression` для получения их значений из `scope`
      var itemExpr = $parse(ngRepeat[1]);
      var itemsExpr = $parse(ngRepeat[2]);
    
      return {
         // Получить модель элемента списка
         item: function (el) {
            return itemExpr(angular.element(el).scope());
         },
         // Получить массив связанный с `ng-repeat`
         items: function () {
            return itemsExpr(scope);
         }
      };
    }

    Пример работы: http://jsbin.com/fumote/1/edit
    Полный код директивы: https://github.com/RubaXa/Sortable/blob/master/ng-sortable.js


    Интеграция с Meteor


    Это совсем новая возможность, которая появилось благодаря Dan Dascalescu, так что если вы используете meteor, то библиотека добавлена в атмосферу, а Dan добавил подробный мануал по использованию и пример. Если что, ставьте задачу с меткой meteor на него, он будет рад помочь ;]


    В завершении хочу сказать спасибо всем тем, кто принимал участие в тестирование и развитии библиотеки, хоть она немного и «потолстела», но это все тот же легкий и гибкий интрумент. Спасибо за внимание.

    Планы на будущее


    • Покрытие тестами (пока не до конца понятно как покрыть Drag'n'Drop, но идеи есть)
    • Улучшенная анимация
    • Система расширений (например вложенные списки или объединение двух элементов в один)
    • Ограничение по осям (увы, возможно придется отказаться от Drag’n’Drop API)
    • Ваш вариант ;]


                   Примеры     |    Код и документация     |    Мой github     |    @ibnRubaXa


    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 24

      +1
      Добавьте ваш модуль на ngmodules.org/, там многие плагины размещены. Мне когда было необходимо найти было удобное решение для драг-дроп сортировки, вашего не было, а оно вполне удобное.

      Хотя и лучше использовать директивы.
        0
        Прочитал документацию. Насчет директив ошибся. Все есть.
          0
          Нет, вы не ошиблись, обязательно добавлю.
            0
            ngmodules.org/modules/Sortable — вот теперь добавил, только не понял как там пример добавить, github markdown ```html .... ``` не работает.
          0
          А что с emberjs? Сложно ли его на нем завести?
            0
            С Ember нет опыта, задача висит, так что пока ничего не могу сказать, будет время, постараюсь изучить, как там создавать подобные компоненты.
            0
            Баг в демке (и, видимо, в либе) — если вниз перетащить первый элемент, то он вставляется посередине, а не в конец.
              0
              В какой именно демке? (со список работает корректно recordit.co/FNOX4Sg2ax)
                0
                Со страницы ng-modules jsbin.com/naduvo/1/edit?html,js,output
                  0
                  Хм, не могу воспроизвести (http://recordit.co/0zkzIIhBLm Chrome 39, OS X)
                    0
                    Проявляется не сразу, а после того как погонять элементы туда-обратно. Сафари, мак. Записал видео: www.dropbox.com/s/eus4ukm7o4our2i/bug.mov?dl=0
                      0
                      Занятно, похоже в директиве есть проблема.
                        0
                        Пока не могу добиться такого поведения даже в Сафари, вечером посмотрю внимательней, спасибо за отзыв.
                  0
                  Если один из списков оставить без элементов, то в него перетащить элемент уже не получится?
                    –1
                    Получиться, нужно только не забыть задать минимальную высоту.
                    0
                    Система расширений (например вложенные списки)

                    Эта фича очень бы пригодилась. Недавно проверял — нет ни одной реализации иерархических сортировочных списков, которая бы без проблем заводилась на meteor. Поэтому ваше внимание к этому фреймворку внушает надежды.
                      0
                      Спасибо!
                      Буду с нетерпением ждать систему расширений.
                        0
                        Лазать по DOM`у в поисках модели, да ещё и комментариях, которые оставляет angular — это просто жесть.
                        Если уж и хотите уделать зубров (которые не додумались до такого), примите как совет:
                        <ul ng-sortable="item in items" options="sortableOptions">
                            <li>{{ item }}</li>
                        </ul>
                        


                        P.S. Я таким образом dropdown для css фреймворка делал.
                        <dropdown items="item in items" default-text="select items" on-select="onSelect(item)">
                            {{item.description}}
                        </dropdown>
                        
                          0
                          Я так не считаю, даже наоборот. Во-вторых не по DOM, а по scope, это нормальный способ, непротиворечащий концепции ангуляра. В-третьих, у модуля четкая зона отвественности, он только дает возможность сортировки, не более и не забирает на себя возможности ng-repeat, что на мой взгляд намного хуже.
                            0
                            Может я не правильно выразился, но…
                            var ngRepeat = [].filter.call(el.childNodes, function (node) {
                                 return (
                                       (node.nodeType === 8) &&
                                       (node.nodeValue.indexOf('ngRepeat:') !== -1)
                                    );
                              })[0];
                            


                            … разве это не лазить по DOM`у в поисках модели (её имени)?

                            и потом…
                            // Прасим название переменных элемента и массива
                              ngRepeat = ngRepeat.nodeValue.match(/ngRepeat:\s*([^\s]+)\s+in\s+([^\s|]+)/);
                            

                              0
                              Согласен, можно назвать это манипуляцией, но этот код выполняется на стадии компиляции, тут не итоговый DOM, как как только в ангуляре появится более элегантная возможность (я не нашел) получить ссылку на модель, это будет переписано. Но повторюсь, это не нарушает соглашения анугляра, данный код никоим образом не воздействует на DOM, он просто получает данные из мета информации и выполняется разово.
                          0
                          Как насчет многоуровневой сортировки?

                          Пример: wadmiraal.github.io/jquery-tabledrag/

                          В свое время потребовалось реализовать такую. Плагин по ссылке чем-то не устроил. В итоге реализовал при помощи вложенных списков jQuery UI Sortable. Пришлось написать много вспомогательного кода, который я так и не покрыл юнит-тестами. А из-за большого количества инстансов Sortable реализация тормозила: на ста элементах становилась неотзывчивой, на тысяче — откровенно тормозила, на десяти тысячах завешивала вкладку Chrome.

                          И это я уже не говорю про мобильники… Для поддержки перетаскивания пальцем я использовал jQuery UI Touch Punch, который завешивал браузер уже на ста элементах.

                          Ваш проект — прямо глоток свежего воздуха. Но без вложенных списков от него мало пользы, т. к. с плоскими и jQuery UI сносно справляется.
                            0
                            Это приоритетная задача, надеюсь в феврале успею закончить, время как обычно против меня. Самый выжный вопрос, как именно это всё должно работать, а именно какие базовые возможности нужно заложить.
                              0
                              С точки зрения конечного пользователя, наиболее удачной мне видится реализация tabledrag. Работает четко и предсказуемо. Никогда не возникало нареканий на нее (в отличие от собственной реализации).

                              С точки зрения разработчика, позаботьтесь о следующих фичах:

                              • Сериализация/маршаллинг дерева для сохранения структуры на сервер.
                              • Восстановление древовидной структуры из данных, поступивших с сервера.
                              • API для манипулирования узлами: перемещение, добавление, удаление, обращение к, поиск. Удаление требуется в двух режимах: с уничтожением всей ветки или с уничтожением одного узла и перемещением дочерних узлов на уровень уничтоженного узла (или в конец списка).
                              • Возможность включения отображения кнопок, позволяющие перемещать узлы тапом, а не перетаскиванием. Это весьма актуально для больших деревьев: проблематично переместить узел перетаскиванием, когда ближайшая ячейка находится за пределами экрана.
                              • При перетаскивании ветки его плэйсходлер должен по размеру соответствовать одинарному узлу, а не перетаскиваемой ветке, иначе таскать большие ветки станет практически невозможно.
                              • Возможность сворачивания/разворачивания веток. При drag over свернутой ветки в течение двух секунд она должна разворачиваься автоматически.
                              • Широкий набор событий на все случаи жизни.
                              • Возможность удобно навешивать колбэки на эти события.

                              Для начала, думаю, хватит. :)

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

                              PS При юнит-тестировании тестируйте методы в изоляции, то есть если метод А запускает метод Б, то при тестировании метода А код метода Б выполняться не должен! Для этого метод Б должен поступать в метод А в качестве аргумента. При нормальной работе кода, метод А берет метод Б из дефолтного значения аргумента. А при тестировании, в качестве метода Б в метод А передается метод-заглушка, созданный при помощи Sinon.

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

                          Самое читаемое