【黒魔術】テキストエリアのキャレット位置をjsで指定した時にスクロールバーを追従させる

## はじめに
`selectionStart`、`selectionEnd`を用いてtextareaのキャレット位置を動的に変更する機会がありました。
すると、たしかにキャレットは移動するのですが画面外に出てしまっているのにスクロールバーがついてこないという現象が見られました。(IE, Chrome, Operaで再現。Firefoxでは問題ありませんでした。)

```html
<!-- コードイメージ -->
<textarea id="textArea" rows="10" cols="200"></textarea>
<button onclick="moveCaret();">キャレット位置変更</button>
<script>
	var textArea = document.getElementById("textArea");
	textArea.selectionStart = 0;
	textArea.selectionEnd = 0;
</script>
```
![キャレット位置変更前.png](https://qiita-image-store.s3.amazonaws.com/0/29682/a6e6f62c-5083-c76c-2822-fc429c94600a.png)
↓
![キャレット位置変更語.png](https://qiita-image-store.s3.amazonaws.com/0/29682/b033b37c-2351-212b-f48b-d58846cb76ca.png)


非常に使い勝手が悪いので、どうにかスクロールバーを追従させる方法を探した所、試行錯誤の末発見しました。

しかし、その方法がなかなか無理矢理で(特にchromeは)黒魔術に近い手段を取る必要があったという話です。

## 対処方法
どのように対処したか説明します。サンプルコードはjQueryに依存しています。

### IE
1. キャレットを移動したい位置に一度適当な文字を挿入する
1. その文字を選択する
1. deleteコマンドを用いて削除する
1. キャレットを移動する

```js
//移動したい位置
var start = 0;
var end = 0:

// 値を保存
value = $(textArea).val();

// キャレット位置に適当な値を挿入した後delete
var part1 = value.substring(0, start);
var part2 = value.substr(end);
$(textArea).val(part1 + "X" + part2);
textArea.selectionStart = part1.length;
textArea.selectionEnd = part1.length + 1;
document.execCommand("delete", false, null);

// 追従した後はvalとキャレットを元に戻す
$(textArea).val(value);
textArea.selectionStart = start;
textArea.selectionEnd = end;

```
2番目の文字列を選択するところが味噌です。これがないと追従しません。
無理やり感がありますがそれでもChromeに比べれば副作用が少ないまっとうな手段です。

### Firefox
対処不要。何もしなくても勝手に追従してくれます。大変優秀。

```js
textArea.selectionStart = start;
textArea.selectionEnd = end;
```

### Chrome, Opera
1. キャレットを移動する
1. blurイベントを発生させる
1. focusイベントを発生させる

```js
textArea.selectionStart = start;
textArea.selectionEnd = end;
$(textArea).trigger("blur").trigger("focus");
```

当然ながら、この方法を用いると**onblueイベントとonfocusイベントは使い物にならなくなります。**大きな副作用が伴う黒魔術です。他に方法はないものか。



## 汎用関数
IE, Firefox, Chromeに対応した汎用の関数を作りました。
キャレット位置を修正した後に呼び出すとスクロールバーが追従します。

```js
// スクロールバー位置更新
// chromeやIEではjsで値を変更してキャレットが画面の外に出ても
// スクロールバーが追従しないのを何とかする関数
var updateScrollPos = function(editor) {
	// 初期状態を保存
	var text = $(editor).val();
	var selectionStart = editor.selectionStart;
	var selectionEnd   = editor.selectionEnd;

	// insertTextコマンドで適当な文字(X)の追加を試みる
	var isInsertEnabled;
	try {
		isInsertEnabled = document.execCommand("insertText", false, "X");
	} catch(e) {
		// IE10では何故かfalseを返さずに例外が発生するのでcatchで対応する
		isInsertEnabled = false;
	}
	
	if (isInsertEnabled) {
		// insertTextに成功したらChrome
		// chromeはどう頑張ってもまっとうな手段が通用しない。
		// 苦肉の策としてselectionStart,endを揃えてから
		// 一旦フォーカスを外してすぐに戻す。そうするとスクロールバーが追従する
		// これにより、chromeではblurやfocusイベントはまともに使えなくなる黒魔術
		$(editor).val(text);
		editor.selectionStart = selectionStart;
		editor.selectionEnd = selectionStart;
		$(editor).trigger("blur", ["kantanEditorDummy"]);
		$(editor).trigger("focus", ["kantanEditorDummy"]);
		
		// valは戻してあるのでキャレット位置のみ元に戻す
		editor.selectionStart = selectionStart;
		editor.selectionEnd = selectionEnd;
	} else {
		// IE,FirefoxはinsertTextに失敗するのでval関数で適当な文字(X)を挿入する。
		// IEではその後にその文字を選択してdeleteコマンドで削除するとスクロールバーが追従する
		var part1 = text.substring(0, selectionStart);
		var part2 = text.substr(selectionEnd);
		$(editor).val(part1 + "X" + part2);
		editor.selectionStart = part1.length;
		editor.selectionEnd = part1.length + 1;
		var isDeleteEnabled = document.execCommand("delete", false, null);
		
		// 追従した後はvalとキャレットを元に戻す
		$(editor).val(text);
		editor.selectionStart = selectionStart;
		editor.selectionEnd = selectionEnd;
	}
}
```

【使用例】

```js
editor.selectionStart = 0;
editor.selectionEnd   = 0;
updateScrollPos(editor);    // キャレット移動後に呼び出す
```



## 他に試したこと(失敗メモ）

### insertTextコマンドを使う
キャレットを移動させた後insertTextで文字を挿入すればスクロールバーが追従するのではと考えました。

```js
textArea.selectionStart = start;
textArea.selectionStart = end;
document.execCommand("insertText", false, " ");
```

結果、追従しませんでした。IEではinsertTextをサポートすらしていません。

### insertHtmlコマンドを使う
insertTextがだめならinsertHtmlならどうだということで試しました。

```js
textArea.selectionStart = start;
textArea.selectionStart = end;
document.execCommand("insertHtml", false, " ");
```
結果は同じでした。

### hide→show
一度隠して表示すればいいのではと思いました。

```js
textArea.selectionStart = start;
textArea.selectionStart = end;
$(textArea).hide().show();
```
chromeで試したのですが、上手く行きませんでした。
うまくいったとしてもこの方法も充分黒魔術なので却下ですね。

### 1行の高さを求め画面外であることを検出する
`scrollHeight`の値を文章の行数で割ると`一行あたりの高さ`が求められます。
`現在の行数 * 一行あたりの高さ`で`キャレットの位置`が求められるので、テキストエリアの`offsetTop`などと比較することでキャレットが画面外に出たことを検出できます。
あとは数式を駆使してキャレット位置と`scrollTop`を合わせれば追従できます。

<small>(追記：コメントでCaret.jsというのを教えてもらったが、多分発想はこれと同じ。)</small>
```
長いのでコード省略
```
これは一見うまくいっているように見えました。
しかしながら、1行が長くて途中で折り返しされていると、一行の高さが正しく求められずにおかしなことになってしまいました。

これはコードが冗長になる以外はまっとうな方法で、行の折り返し設定がなければIE,Firefox,Chrome全てで上手くいきます。しかし今回は折り返し設定があったので残念ながら見送りました。


### indentコマンド→outdentコマンド
indentした後にoutdentしたら、追従するのではないかと考えました。

```js
textArea.selectionStart = start;
textArea.selectionStart = end;
document.execCommand("indent", false, null);
document.execCommand("outdent", false, null);
```

ダメでした。textareaではindentとoutdentがうまいこと動いてくれませんでした。

### 矢印キーのイベントを起こす
キャレットが画面外に出ても、矢印キーを押すと追従してくれます。そこで、矢印キーのイベントを無理やり発生させればうまくいくのではと考えました。

結果、上手いようにイベントを発生させることができずに断念しました。


### pasteコマンド→undoコマンド
ペーストした後に元に戻せばうまくいくのではと考えました。

```js
textArea.selectionStart = start;
textArea.selectionStart = end;
document.execCommand("paste", false, null);
document.execCommand("undo", false, null);
```
これはchromeではダメでしたがIEではうまくいきました。
しかしながらクリップボードに必ず文字列が入っているとは限らないし、「無理やりクリップボードに文字列を詰めて・・・」などと考えるとさすがに黒魔術すぎるので採用は見送りました。




## 結論
無理やり対応する方法はある。しかし、特にChromeにおいては副作用が大きすぎるので慎重に検討すること。