React.jsで、textarea上でTabキーを押したときにスペースを入力する

通常textareaにフォーカスが当たっている時にTabキーをタイプすると、次の対象のオブジェクトへとフォーカスが移動してしまいます。
しかし、textareaでTabキーをタイプするとスペース4つが入力されるようにするには少し困ったので記事にします。


# 実行環境
以下の環境で実行できたものを記事にしています。

- Node.js: 8.9.4
- React.js: 16.2.0
- babel-preset-env: 1.6.1
  - blowsers: last 2 versions (2018年1月14日時点)


# とりあえずコード
```javascript
class SomeTextarea extends React.Component {

  constructor(props) {
    super(props);
    this.state = {
      'text': '',
    };
  }
  
  handleChange(e) {
    this.setState({ text: e.target.value });
  }

  handleKeyDown(e) {

    if (e.key === 'Tab' && e.keyCode !== 229) {
      e.preventDefault();
      
      const textareaElement = e.target;
      
      const currentText = textareaElement.value;
      
      const start = textareaElement.selectionStart;
      const end = textareaElement.selectionEnd;
      
      const spaceCount = 4;
      const substitution = Array(spaceCount + 1).join(' ');
      
      const newText = currentText.substring(0, start) + substitution + currentText.substring(end, currentText.length);

      this.setState({
        text: newText,
      }, () => {
        textareaElement.setSelectionRange(start + spaceCount, start + spaceCount);
      });
    }
  }

  render() {
    return (
      <textarea
        onChange={this.handleChange.bind(this)}
        onKeyDown={this.handleKeyDown.bind(this)}
      ></textarea>
    );
  }
}
```

# 説明
## ChangeイベントとKeyDownイベントを利用する
```javascript
  handleChange(e) {
    // Changeイベントでは普通に値を更新する
    // この時、Tabキーをタイプした時は何も値が変化しない(そもそも何も入力しないため)
    this.setState({ text: e.target.value });
  }
  
  handleKeyDown(e) {
    // タイプしたキーがTabキーの時 かつ 日本語入力未確定状態でない時に実行する
    if (e.key === 'Tab' && e.keyCode !== 229) {
      ...
    }
  }
  
  render() {
    return (
      <textarea
        onChange={this.handleChange.bind(this)}
        onKeyDown={this.handleKeyDown.bind(this)}
      ></textarea>
    );
  }  
```

ここでは`onChange`と`onKeyDown`の二種類のイベントを補足するようにしています。
`onChange`属性では、入力された文字を通常通り更新します。
同時に`onKeyDown`も補足するようにし、Tabキーがタイプされた時のみ特別な処理（今回であればスペース4つを挿入する）を行うようにします。
この時、`keyCode`が`229`でないかどうかも判定していますが、これはIMEが日本語入力になっている状態で、IMEの変換候補の選択を行う際のTabキータイプではこの処理を行わないようにするためです。これをしないと変換候補の選択の度に4スペースが挿入されてしまうことになります。

※ ただし、[KeyboardEvent.keyCodeはdeprecated](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/keyCode)なので、今後も使える方法ではありません。
本来であればIMEによる入力文字の未確定状態はKeyboardEventの`isComposing`プロパティから検出することができるので、判定式を `e.key === 'Tab' && !e.isComposing` などとすることでdeprecatedではない方法で実現できるはずです。
しかし、`KeyboardEvent.isComposing`はIEとSafariでサポートされておらず、まだこれを使うには時期尚早ということで`keyCode`を使う方法を紹介してします。（この部分で良い方法をご存知の方がいたらご教授していただきたく）


## Tabキーによるフォーカスの移動を抑制する
```javascript
    e.preventDefault();
```

Tabキーが入力された場合、イベントをキャンセルしないと次の対象のオブジェクトへフォーカスが移動してしまします。
そこで、[KeyboardEvent](https://developer.mozilla.org/ja/docs/Web/API/KeyboardEvent)の`preventDefault()`を使うことによって、その挙動を抑制します。


## 現在の選択範囲を取得する
```javascript
      // focusしているtextarea Elementを取得
      const textareaElement = e.target;
      
      // 現在の入力されている文字列
      const currentText = textareaElement.value;
      
      // 現在の選択範囲の先頭側のインデックス
      const start = textareaElement.selectionStart;
      // 過去の選択範囲の末尾側のインデックス
      const end = textareaElement.selectionEnd;
```

まず、`e.target`から`<textarea>`のDOMオブジェクトである[HTMLTextAreaElement](https://developer.mozilla.org/ja/docs/Web/API/HTMLTextAreaElement)を取得します。
そこから現在の入力されている文字列と、選択されている範囲の先頭・末尾のインデックスを取得します。

- 範囲選択をしていない場合

<img width="446" alt="スクリーンショット 2018-01-14 19.59.20.png" src="https://qiita-image-store.s3.amazonaws.com/0/41680/afcea16b-0b05-2eef-9dfa-e3197b66b913.png">

- 範囲選択をしている場合

<img width="444" alt="スクリーンショット 2018-01-14 19.58.17.png" src="https://qiita-image-store.s3.amazonaws.com/0/41680/c5072821-27a0-127b-102d-78db27bb739c.png">


## スペースを挿入した文字列を作る
```javascript
      // スペースの数を定義（以下は4スペースの場合）
      const spaceCount = 4;
      // Tabキーを押したときに入力する文字（今回のケースでは '    ' となる）
      const substitution = Array(spaceCount + 1).join(' ');
      
      // 選択されている部分文字列を substitution に置換する（もっとスッキリ書けそう…?）
      const newText = currentText.substring(0, start) + substitution + currentText.substring(end, currentText.length);
```

ここでスペース4つを挿入した新たな文字列を作っています。
`substitution`がスペース4つの文字列であり、それと選択範囲のインデックスを使って選択範囲部分を`substitution`に置換しています。

<img width="649" alt="スクリーンショット 2018-01-14 20.19.35.png" src="https://qiita-image-store.s3.amazonaws.com/0/41680/1f1cc347-acf9-9392-7a61-401853944985.png">


## スペース挿入後の文字列をセットし、カーソルを移動させる
```javascript
      // 置換後の文字列をセットする
      // また、置換後の文字列のセットが反映された後に、カーソルの位置の修正処理を行うようにする
      this.setState({
        text: newText,
      }, () => {
        // 入力中のカーソルを置き換えたスペースの末尾に移動させる
        textareaElement.setSelectionRange(start + spaceCount, start + spaceCount);
      });
```

textareaの入力文字列を更新します。
その更新が完了してから、入力カーソルの位置を挿入したスペース4つの末尾側へ移動させます。コードではカーソルの現在位置 + スペース4文字分 の位置としています。
setStateのcallbackを利用しないと、stateの更新前にカーソルの移動処理が行われ、おかしな場所にカーソルが移動してしまうので注意です。


# さいごに
以上で、Tabキーを押した時にスペースを入力することができました。ただし手元の環境にIEがなかったことからIEでの検証ができているわけではないので、もしできなかったらお知らせいただければと思います。

ちなみにReact.jsを使っているならば、facebookの[Draft.js](https://draftjs.org/)を使うことでこの挙動を実現できるようです。
https://github.com/facebook/draft-js/issues/121
（あんまりbundleしたJSファイルのサイズを大きくしたくなかったので、今回は使わなかったということでした。）