react-dnd…Reactjsのドラッグ並び替え処理

` React Components`で[DRAG](http://react-components.com/search/drag)、[SORTABLE](http://react-components.com/search/sortable)などで検索すると出てくる中、星が一番多いのが[react-dnd](https://github.com/gaearon/react-dnd)ですが、恐らく一番少ないコード量であろう、[04 Sortable/Simple](https://github.com/gaearon/react-dnd/tree/v2.0.2/examples/04%20Sortable/Simple)の中身がなかなか禍々しく、かつ[flux](http://azu.github.io/slide/react-meetup/flux.html)でも無いため、読むのに苦労しました。

そこで

* 環境設定
* どうすれば`react-dnd@2.0.2`が最低限動くのか
* `view`に`action` / `store`を付与して操作する
* mobile対応（申し訳程度の）

を考えた手順を紹介します。

環境設定
---

以下の`package.json`を使用します。

```json
{
  "name": "try-dnd",
  "scripts": {
    "start": "webpack-dev-server --port 59798 --host 0.0.0.0 --content-base dist",
    "build": "webpack"
  },
  "dependencies": {
    "react": "^0.14.2",
    "react-addons-update": "^0.14.2",
    "react-dnd": "^2.0.2",
    "react-dnd-html5-backend": "^2.0.0",
    "react-dnd-touch-backend": "^0.2.0",
    "react-dom": "^0.14.2"
  },
  "devDependencies": {
    "babel-core": "^5.8.33",
    "babel-loader": "^5.3.3",
    "webpack": "^1.12.4",
    "webpack-dev-server": "^1.12.1"
  },
  "babel": {
    "stage": 0
  }
}
```

以下の`webpack.config.babel.js`を使用します。

```js
import webpack from 'webpack'

export default {
  context: __dirname + '/src',
  entry: {
    javascript: './index.js',
  },

  output: {
    path: __dirname + '/dist',
    filename: 'bundle.js',
  },

  module: {
    loaders: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        loaders: ['babel-loader'],
      },
    ],
  },

  devtool: '#source-map',
  plugins: [
    // new webpack.optimize.UglifyJsPlugin({compress:{warnings:false}}),
    new webpack.optimize.OccurenceOrderPlugin(),
    new webpack.HotModuleReplacementPlugin(),
    new webpack.NoErrorsPlugin()
  ],
}
```

エントリポイントである`src`、ビルド先`dist`を生成し、ブラウザでの確認用`index.html`ファイルを作成します。

```bash
mkdir src dist
touch dist/index.html
```

```html:index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>react-dnd</title>
</head>
<body>

  <script src="bundle.js"></script>
  <main></main>
  
</body>
</html>
```

`src/index.js`に動作確認用のComponentを定義します。

```js:src/index.js
import React from 'react'
import ReactDOM from 'react-dom'

class Sortable extends React.Component{
  constructor(props){
    super(props)

    this.state= {
      items: [
        {id:0,text:'Lorem ipsum dolor sit amet'},
        {id:1,text:'consectetur adipisicing elit'},
        {id:2,text:'Voluptatibus repellendus vitae officia'},
        {id:3,text:'earum ea eligendi voluptates quidem harum'},
        {id:4,text:'voluptas numquam aliquam cum eaque?'},
      ],
    }
  }

  render(){
    return(
      <ul>
        {
          this.state.items.map((item,i) =>
            <li id={item.id} key={i}>{item.text}</li>
          )
        }
      </ul>
    )
  }
}

addEventListener('load',()=>{
  ReactDOM.render(<Sortable />,document.querySelector('main'))
})
```

必要なファイルが準備できたので、最後に`npm install`で依存を解消します。

```bash
npm install
# npm WARN EPACKAGEJSON ... (無視)

tree .
# .
# ├── dist
# │   └── index.html
# ├── node_modules
# │   └── (330 directories...)
# ├── package.json
# ├── src
# │   └── index.js
# └── webpack.config.babel.js
```

`npm start`で開発用サーバーが起動するので、http://localhost:59798/webpack-dev-server/ で`src/index.js`のコンパイル結果を`index.html`経由で確認します。

![スクリーンショット 2015-11-11 23.30.46.png](https://qiita-image-store.s3.amazonaws.com/0/28576/68e70023-894a-2772-d014-05460af2e699.png)

表示を確認したら、この`li`要素に並び替えの要素を加えていく手順を説明します。

どうすれば`react-dnd@2.0.2`が最低限動くのか
---

まず、先ほど作成した`Sortable`コンポーネントに[Decorator構文](https://github.com/wycats/javascript-decorators)で`DragDropContext(ReactDnDHTML5Backend)`を注入します。

```js:index.js
// ...
import {DragDropContext,DropTarget,DragSource} from 'react-dnd'
import ReactDnDHTML5Backend from 'react-dnd-html5-backend'

@DragDropContext(ReactDnDHTML5Backend)
class Sortable extends React.Component{
// ...
```

次に、ドラッグ＆ドロップする要素を別コンポーネントとして定義し直します。

```js:index.js>Sortable>render()
// ...
          this.state.items.map((item,i) =>
            // li -> Item
            <Item id={item.id} key={i}>{item.text}</Item>
          )
// ...
```

```js
//...
class Item extends React.Component{
  render(){
    return (
      <li>
        {this.props.children}
      </li>
    )
  }
}
//...
```

定義し直した`Item`コンポーネントに、ドラッグ元であることを定義する`DragSource`、ドラッグ先であることを定義する`DropTarget`をDecorator構文で注入し、`Item`コンポーネントに注入されたラッパ関数`this.props.connectDragSource`,`this.props.connectDropTarget`で、render時の要素を梱包してください。

```js:DecorateされたItemコンポーネント
@DropTarget('item',{
  hover(hoverProps,monitor,hoverComponent){
    let dragProps= monitor.getItem()

    console.log(hoverProps.id,dragProps.id)
  },
},(connect)=>{
  return {
    connectDropTarget: connect.dropTarget(),
  }
})
@DragSource('item',{
  beginDrag(props){
    return props
  }
},(connect,monitor)=>{
  return {
    connectDragSource: connect.dragSource(),
    isDragging: monitor.isDragging(),
  }
})
class Item extends React.Component{
  render(){
    return this.props.connectDragSource(this.props.connectDropTarget(
      <li>
        {this.props.children}
      </li>
    ))
  }
}
```

以上のコードは、`src/index.js`上で全て書いたことを想定した説明です。結果、ソースコードは下記のようになります。

```js:src/index.js
import React from 'react'
import ReactDOM from 'react-dom'

import {DragDropContext,DropTarget,DragSource} from 'react-dnd'
import ReactDnDHTML5Backend from 'react-dnd-html5-backend'

@DragDropContext(ReactDnDHTML5Backend)
class Sortable extends React.Component{
  constructor(props){
    super(props)

    this.state= {
      items: [
        {id:0,text:'Lorem ipsum dolor sit amet'},
        {id:1,text:'consectetur adipisicing elit'},
        {id:2,text:'Voluptatibus repellendus vitae officia'},
        {id:3,text:'earum ea eligendi voluptates quidem harum'},
        {id:4,text:'voluptas numquam aliquam cum eaque?'},
      ],
    }
  }

  render(){
    return(
      <ul>
        {
          this.state.items.map((item,i) =>
            <Item id={item.id} key={i}>{item.text}</Item>
          )
        }
      </ul>
    )
  }
}

@DropTarget('item',{
  hover(hoverProps,monitor,hoverComponent){
    let dragProps= monitor.getItem()

    console.log(hoverProps.id,dragProps.id)
  },
},(connect)=>{
  return {
    connectDropTarget: connect.dropTarget(),
  }
})
@DragSource('item',{
  beginDrag(props){
    return props
  }
},(connect,monitor)=>{
  return {
    connectDragSource: connect.dragSource(),
    isDragging: monitor.isDragging(),
  }
})
class Item extends React.Component{
  render(){
    return this.props.connectDragSource(this.props.connectDropTarget(
      <li>
        {this.props.children}
      </li>
    ))
  }
}

addEventListener('load',()=>{
  ReactDOM.render(<Sortable />,document.querySelector('main'))
})
```

ここまでをコンパイルすると、ドラッグしている要素と、マウスの上の要素のpropsを、`console.log`を吐き出すような結果になります。

![foo.gif](https://qiita-image-store.s3.amazonaws.com/0/28576/271ebbfe-3bee-469e-9177-d9cb44629851.gif)

`view`に`action` / `store`を付与して操作する
---

* 重なった要素が別idであれば、state.itemsを並び替えてviewを更新する

を、action->store->views->actionの[簡易flux](http://azu.github.io/slide/react-meetup/flux.html)で行います。

`Action`クラス、`Store`クラスを定義し、コンポーネントのビジネスロジックやハードコーディングを、所定のクラスへ移動させます。

```js
import React from 'react'
import ReactDOM from 'react-dom'

import {DragDropContext,DropTarget,DragSource} from 'react-dnd'
import ReactDnDHTML5Backend from 'react-dnd-html5-backend'

import EventEmitter from 'events'
import update from 'react-addons-update'

@DragDropContext(ReactDnDHTML5Backend)
class Sortable extends React.Component{
  constructor(props){
    super(props)

    this.state= this.props.store.get()
    this._onChange= ::this.onChange
  }
  onChange(){
    this.setState(this.props.store.get())
  }
  componentDidMount(){
    this.props.store.on('change',this._onChange)
  }
  componentWillUnmount(){
    this.props.store.removeListener('change',this._onChange)
  }

  render(){
    return(
      <ul>
        {
          this.state.items.map((item,i) =>
            <Item action={this.props.action} id={item.id} key={i}>{item.text}</Item>
          )
        }
      </ul>
    )
  }
}

@DropTarget('item',{
  hover(hoverProps,monitor,hoverComponent){
    let dragProps= monitor.getItem()
    if(hoverProps.id === dragProps.id){
      return
    }

    hoverProps.action.emit('exchange',dragProps.id,hoverProps.id)
  },
},(connect)=>{
  return {
    connectDropTarget: connect.dropTarget(),
  }
})
@DragSource('item',{
  beginDrag(props){
    return props
  }
},(connect,monitor)=>{
  return {
    connectDragSource: connect.dragSource(),
    isDragging: monitor.isDragging(),
  }
})
class Item extends React.Component{
  render(){
    return this.props.connectDragSource(this.props.connectDropTarget(
      <li>
        {this.props.children}
      </li>
    ))
  }
}

class Action extends EventEmitter{
  exchange(aId,bId){
    this.emit('exchange',aId,bId)
  }
}
class Store extends EventEmitter{
  constructor(action){
    super()
    
    this.data= {
      items: [
        {id:0,text:'Lorem ipsum dolor sit amet'},
        {id:1,text:'consectetur adipisicing elit'},
        {id:2,text:'Voluptatibus repellendus vitae officia'},
        {id:3,text:'earum ea eligendi voluptates quidem harum'},
        {id:4,text:'voluptas numquam aliquam cum eaque?'},
      ],
    }

    action.on('exchange',::this.onExchange)
  }
  onExchange(aId,bId){
    let aItem= this.data.items.filter(item => item.id === aId)[0]
    let aIndex= this.data.items.indexOf(aItem)
    let bItem= this.data.items.filter(item => item.id === bId)[0]
    let bIndex= this.data.items.indexOf(bItem)

    this.data= update(this.data,{items:{
      $splice: [[aIndex,1,bItem]],
    }})
    this.data= update(this.data,{items:{
      $splice: [[bIndex,1,aItem]],
    }})

    this.emit('change')
  }
  get(){
    return this.data
  }
}

addEventListener('load',()=>{
  const action= new Action
  const store= new Store(action)
  ReactDOM.render(<Sortable action={action} store={store} />,document.querySelector('main'))
})
```

また同時に、actionから`exchange`イベントを受け取った`store`が、自身の`onExchange`メソッドで、Reactの[updateアドオン](https://facebook.github.io/react/docs/update.html)を使用して、`this.data.items`を入れ替えていることに注意して下さい。

```js
// ...
  onExchange(aId,bId){
    let aItem= this.data.items.filter(item => item.id === aId)[0]
    let aIndex= this.data.items.indexOf(aItem)
    let bItem= this.data.items.filter(item => item.id === bId)[0]
    let bIndex= this.data.items.indexOf(bItem)

    this.data= update(this.data,{items:{
      $splice: [[aIndex,1,bItem]],
    }})
    this.data= update(this.data,{items:{
      $splice: [[bIndex,1,aItem]],
    }})

    this.emit('change')
  }
// ...
```

変更が完了したら`change`イベントを発行し、`store`を購読しているコンポーネントに`state`の更新を促します。

結果は下記のとおりです。

![foo.gif](https://qiita-image-store.s3.amazonaws.com/0/28576/cd5320d9-5aac-52a6-c260-92375be110af.gif)

mobile対応（申し訳程度の）
---
> The second package instructs React DnD to use the HTML5 drag and drop API under the hood. You may choose to use a third-party backend instead, such as [the touch backend](https://github.com/yahoo/react-dnd-touch-backend).
> <p align="right">http://gaearon.github.io/react-dnd/</p>

とあるように、`DragDropContext`デコレータで注入している`ReactDnDHTML5Backend`を

```js
import ReactDnDTouchBackend from 'react-dnd-touch-backend'

@DragDropContext(ReactDnDTouchBackend)
// ...
```

とすると動くのですが、今度は通常のPCでドラッグ＆ドロップしなくなります。
なので、[正規表現で`navigator.userAgent`を見て](http://stackoverflow.com/questions/11381673/detecting-a-mobile-browser)、どちらを注入するかを直前に決めてください。

```js
import {DragDropContext,DropTarget,DragSource} from 'react-dnd'
import ReactDnDHTML5Backend from 'react-dnd-html5-backend'
import ReactDnDTouchBackend from 'react-dnd-touch-backend'

const isMobile= navigator.userAgent.match(/(Android|webOS|iPhone|iPad|iPod|BlackBerry|Windows Phone)/i) !== null

@DragDropContext(isMobile? ReactDnDTouchBackend: ReactDnDHTML5Backend)
class Sortable extends React.Component{
// ...
```

[^hoka]: `::` [Function Bind Syntax](https://github.com/zenparsing/es-function-bind#readme) 参照。`onChange= ()=>{}`のような[擬似autobind](https://gist.github.com/jeffmo/054df782c05639da2adb#gistcomment-1467210)でも良いんですが、これはbabel@5のコンパイル結果見るとconstructor内で`this`にプロパティとして関数を突っ込んでいるだけなので、`super`で呼び出せなくなる弊害があったり、そもそも[babel@6で動作しません](https://github.com/babel/babel/issues/2774)。

![PC/iPhone](https://qiita-image-store.s3.amazonaws.com/0/28576/99cdcc18-9113-436f-86f9-67a07c2e1256.gif)


[…上記までのソースコード](https://gist.github.com/59naga/ed6714519284d36792ba)

参考
---
* [(cjsx/CoffeScript) Reactでドラッグアンドドロップを実装したいので、react-dndを使ってみた | WebDesign Dackel](http://webdesign-dackel.com/2015/06/04/react-dnd/)