続編 JavaScript - Qiitaのtextarea自動補完がOSSになりました
GitHubのコメントでは@と入力するとカーソルの下に入力補完が出現する。さらっとやっているが、実はこれが結構難しい。なぜ難しいのかというと、JavaScriptではカーソルが何文字目にいるかは分かるが、 カーソルのXY座標を取得するAPIが存在しない からだ。カーソル位置が分からなければ、適切な位置に補完候補を表示することができない。では一体どうすればいいのか?
今回Qiitaではコメント欄でのメンションの補完機能を実装した。本稿では前述の問題を解決するために用いたテクニックを解説する。
ちなみにこのメンション補完機能はチーム用プライベートQiitaである「Qiita:Team」でも勿論使える。現在絶賛無料トライアル実施中なので、興味を持たれた方はそちらも使ってみて欲しい。
要約
textarea内でのカーソル位置を計算するには...
- ダミーのdiv要素を画面外に作り、textarea要素のスタイルを模倣する
- 先頭からカーソルまでの文字列をコピーし、div要素に挿入する
- 末尾にspan要素を追加し、そのspan要素のdiv要素内での相対位置を計算する
ただしこれはカーソル位置を計算する唯一の方法ではなく、他にも方法は考えられる。
この記事を読むと分かること
- textarea内でのカーソルの相対位置を計算する方法
この記事を読んでも分からないこと
- 補完メニューを良い感じの場所に設置する方法
- 補完メニューの実装方法
カーソルのXY座標を計算する
カーソルの下に補完メニューを表示するには、カーソルのXY座標が分からなければならない。だが、冒頭でも述べたようにJavaScriptにはカーソルのXY座標を取得するAPIが存在しない。そのため、何かしらの方法を用いて自ら計算方法を実装しなければならない。
結論から言えば、カーソル位置はtextareaのスタイルと、カーソルの前にどのような文字列が存在するかによって決定される。つまり、これらを完全に模倣してやればいいわけだ。
カーソルが何文字目にいるか取得する
カーソルが何文字目にいるかを取得するAPIは、モダンなブラウザであればtextareaのselectionStart
もしくはselectionEnd
で取得できる。これらは基本的に同じ値が格納されており、ドラッグして範囲を選択した場合のみ値が変わる。名前からも分かるようにselectionStart
からselectionEnd
までがドラッグされているわけだ。
var textarea = document.getElementsByTagName('textarea')[0],
start = textarea.selectionStart;
selectionEnd
が実装されていないようなブラウザもサポートするには、もっと込み入った処理が必要になる。この点についてはStackOverflowのこの解答が詳しい。
先頭からカーソル位置までの文字列を取得する
カーソルが何文字目にいるかを取得するgetCaret
関数が実装できたとする。先頭からカーソル位置までの文字列を取得するコードは次のようになる。
// 先頭からカーソル位置までを取得する
var text = textarea.value.substring(0, getCaret(textarea));
これでカーソル位置の計算に必要なtextarea要素内の文字列を取り出すことができるようになった。
textareaを模倣したdiv要素を用意する
次に先ほど取得した文字列を入れるための容れ物になるdiv要素を準備する。このとき、この要素がtextareaと同様のスタイルになっていなければ正しくカーソル位置を計算できないことは言うまでもない。
textarea要素からコピーするスタイルは次のものだ(抜けがある可能性もある)。逆に言えば、これらの値が一致していれば中身の文字列の大きさや間隔などが一致することを意味する。
- border-bottom-width
- border-left-width
- border-right-width
- border-top-width
- font-family
- font-size
- font-style
- font-variant
- font-weight
- height
- letter-spacing
- word-spacing
- line-height
- padding-bottom
- padding-left
- padding-right
- padding-top
- text-decoration
- width
加えてdiv要素を画面外に描画しなければならない。これらを実装すると次のようになる。
var i,
div = document.createElement('div'),
list = ['border-bottom-width', 'border-left-width', 'border-right-width',
'border-top-width', 'font-family', 'font-size', 'font-style',
'font-variant', 'font-weight', 'height', 'letter-spacing',
'word-spacing', 'line-height', 'padding-bottom', 'padding-left',
'padding-right', 'padding-top', 'text-decoration', 'width'];
// 画面外に配置する
div.style.position = 'absolute';
div.style.top = 0;
div.style.left = -9999;
// textareaのスタイルをコピーする
for (i = 0; i < list.length; i++) {
div.style[list[i]] = textarea.style[list[i]];
}
// divを画面に挿入する
document.body.appendChild(div);
カーソルまでの文字列とspanを末尾に入れてspanの位置を計算する
textareaと同じスタイルのdivが用意できた。あとはカーソルまでの文字列をその中に入れ、それに続くかたちでspanを入れて、その位置を取得するだけだ。
var span = document.createElement('span');
// spanに大きさをもたせるために適当な文字列を挿入
span.innerHTML = ' ';
// 文字列を挿入
div.textContent = text;
// スクロール位置を調整
div.scrollTop = div.scrollHeight;
// spanを挿入
div.appendChild(span);
// spanの位置を取得。簡単のためにjQueryを使っている
var position = $(span).position();
// divの左上から見たときの相対位置
position.top;
position.left;
ここで得られたposition
はspanの親要素であるdivからの相対的な位置になっている。そしてこの値はtextareaの左上から見たカーソルの相対位置と等しい。
textareaの補完メニューを実装するには
これでカーソルの位置は取得できるようになった。だが道はまだ半ばだ。実際に動作する補完メニューを実装するにはこのあとさらに次のようなステップを踏むことになる。
- textareaの内容から検索文字列を検出する
- それに対して検索を実行する
- 補完メニューをカーソルの下に表示する
- 検索結果を補完メニューに表示する
- 補完メニューをクリック可能にする
- 選択された値を用いてtextareaを更新する
Qiitaの場合はこれらに加えてさらに
- コードの中だったら補完メニューを表示しない
- キーボードでメニューを選択可能
- 再利用性を高めるためにBackboneのViewのためのMixinとして実装
などを行なっている。
これら残りの工程についてここでは時間の関係上解説しないが、本稿の反響が良ければ書くかも知れない。「残りについても解説記事希望!」という場合は是非ともその旨を、このページの下にあるコメント欄で私にメンションを飛ばして知らせて欲しい。