sortableで並び替えたらBackbone.Collection,Viewも並び替えるjQueryプラグイン

# 前段

今更ながらBackbone.jsネタです。

jQueryUIの`sortable`を使えば、ドラッグ＆ドロップによる要素の並び替えが簡単に実現できます。ただ、Backbone.jsと使用する場合にはちょっと面倒なことがあります。

通常Backboneで一覧系の画面を描画するときは、リストの一行に相当するModelとViewを定義し、リストのコンテナにCollectionとコンテナViewを対応させるのが定石だと思います。サンプルコードを挙げます。

```javascript:example.js
var CollectionView = Backbone.View.extend({
  tagName: 'ul',
  
  render: function() {
    this.itemViews = [];
    var that = this;
    this.collection.each(function(model) {
      that.add(model);
    });
    
    return this;
  },
  
  add: function(model) {
    var itemView = new ItemView({ model: model });
    this.$el.append(itemView.render().el);
    this.itemViews.push(itemView);
  },
});

var ItemView = Backbone.View.extend({
  tagName: 'li',
  
  render: function() {
    ...
    ...
    return this;
  },
});
```

ここで`li`要素を`sortable`で並び替えられるようにするのは簡単なのですが、ドラッグ＆ドロップで並び替えた後の画面上の並びに`collection`・`itemViews`を同期させてやらないと、後々問題になることがよくあります。

というわけで、並び替え後にcollectionやviewsも自動的に並び替えてくれる、jQueryUIのsortableをラップしたbackboneSortableというjQueryプラグインを作成しました。

# backboneSortable.js

コードは以下の通りです。

```javascript:backboneSortable.js
/**
 * $.backboneSortable
 */
(function(){
  var methods = {
    init: function(options) {
      var settings = $.extend({
        // defaults
      }, options);

      return this.each(function() {
        var $this = $(this);
        var backboneSortable = new BackboneSortable($this, $.extend({}, settings, $this.data()));
        backboneSortable.sortable();

        $this.data('plugin_backbone_sortable', backboneSortable);
      });
    },
  };

  $.fn.backboneSortable = function(method) {
    if (methods[method]) {
      return methods[method].apply(this, Array.prototype.slice.call(arguments, 1));
    } else if (typeof method === 'object' || !method) {
      return methods.init.apply(this, arguments);
    } else {
      $.error('Method ' +  method + ' does not exist on jQuery.backboneSortable');
    }
  };

  /**
   * BackboneSortable class
   */
  var BackboneSortable = (function() {

    var NAMESPACE = '.backboneSortable';

    var BackboneSortable = function($el, settings) {
      this.$el = $el;
      this.settings = settings;
    };

    // instance methods
    $.extend(BackboneSortable.prototype, {
      sortable: function() {
        var that = this;
        var fromIndex;
        this.$el.sortable($.extend({}, this.settings, {
          start: function(e, ui) {
            fromIndex = ui.item.index();
            that._delegate('start', e, ui);
          },
          update: function(e, ui) {
            var toIndex = ui.item.index();
            // Move a view
            var view = that.settings.backboneViews.splice(fromIndex, 1)[0];
            that.settings.backboneViews.splice(toIndex, 0, view);
            // Move a model
            that.settings.backboneCollection.remove(view.model, { silent: true });
            that.settings.backboneCollection.add(view.model, { at: toIndex, silent: true });
            that.settings.backboneCollection.trigger('sort' + NAMESPACE,
                                                     that.settings.backboneCollection,
                                                     { model: view.model, from: fromIndex, to: toIndex });
            that._delegate('update', e, ui);
          },
        }));
      },

      _delegate: function(funcName, e, ui) {
        var f = this.settings[funcName];
        if (typeof f === 'function') {
          f.call(this.$el, e, ui);
        }
      },
    });

    return BackboneSortable;
  })();
})();
```

# Usage

使い方は、こんな感じです。

```javascript:usage.js
var CollectionView = Backbone.View.extend({
  tagName: 'ul',
  
  initialize: function(options) {
    this.listenTo(this.collection, 'sort.backboneSortable', function(collection, options) {
      console.log('model moved', options.from, '->', options.to, options.model);
    });
  },
  
  render: function() {
    this.itemViews = [];
    var that = this;
    this.collection.each(function(model) {
      that.add(model);
    });
    
    this.$el.backboneSortable({
      backboneCollection: this.collection,
      backboneViews: this.itemViews,
      update: function(e, ui) {
        // Both the collection and the views have been updated as they are
        console.log(that.collection.pluck('name'), _.pluck(that.itemViews, 'cid'));
        console.log(this.sortable('toArray'));
      },
    });
    
    return this;
  },
  
  add: function(model) {
    var itemView = new ItemView({ model: model });
    this.$el.append(itemView.render().el);
    this.itemViews.push(itemView);
  },
});

var ItemView = Backbone.View.extend({
  tagName: 'li',
  
  render: function() {
    this.$el
      .text(this.model.get('name') + ' - ' + this.cid)
      .attr('id', this.model.id);
    return this;
  },
});


var collection = new Backbone.Collection([
  new Backbone.Model({ id: 'J', name: 'John' }),
  new Backbone.Model({ id: 'S', name: 'Sarah' }),
  new Backbone.Model({ id: 'A', name: 'Andrew' }),
]);

var collectionView = new CollectionView({ collection: collection });
$('body').append(collectionView.render().el);
```