LoginSignup
26
23

More than 5 years have passed since last update.

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

Last updated at Posted at 2016-02-13

はじめに

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

<!-- コードイメージ -->
<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

キャレット位置変更語.png

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

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

対処方法

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

IE

  1. キャレットを移動したい位置に一度適当な文字を挿入する
  2. その文字を選択する
  3. deleteコマンドを用いて削除する
  4. キャレットを移動する
//移動したい位置
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

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

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

Chrome, Opera

  1. キャレットを移動する
  2. blurイベントを発生させる
  3. focusイベントを発生させる
textArea.selectionStart = start;
textArea.selectionEnd = end;
$(textArea).trigger("blur").trigger("focus");

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

汎用関数

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

// スクロールバー位置更新
// 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;
    }
}

【使用例】

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

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

insertTextコマンドを使う

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

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

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

insertHtmlコマンドを使う

insertTextがだめならinsertHtmlならどうだということで試しました。

textArea.selectionStart = start;
textArea.selectionStart = end;
document.execCommand("insertHtml", false, " ");

結果は同じでした。

hide→show

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

textArea.selectionStart = start;
textArea.selectionStart = end;
$(textArea).hide().show();

chromeで試したのですが、上手く行きませんでした。
うまくいったとしてもこの方法も充分黒魔術なので却下ですね。

1行の高さを求め画面外であることを検出する

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

(追記:コメントでCaret.jsというのを教えてもらったが、多分発想はこれと同じ。)

長いのでコード省略

これは一見うまくいっているように見えました。
しかしながら、1行が長くて途中で折り返しされていると、一行の高さが正しく求められずにおかしなことになってしまいました。

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

indentコマンド→outdentコマンド

indentした後にoutdentしたら、追従するのではないかと考えました。

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

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

矢印キーのイベントを起こす

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

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

pasteコマンド→undoコマンド

ペーストした後に元に戻せばうまくいくのではと考えました。

textArea.selectionStart = start;
textArea.selectionStart = end;
document.execCommand("paste", false, null);
document.execCommand("undo", false, null);

これはchromeではダメでしたがIEではうまくいきました。
しかしながらクリップボードに必ず文字列が入っているとは限らないし、「無理やりクリップボードに文字列を詰めて・・・」などと考えるとさすがに黒魔術すぎるので採用は見送りました。

結論

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

26
23
6

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
26
23