ブラウザで動くエディタを開発していると避けては通れない contenteditable な caret の位置(座標)。いろいろなアプローチがありますが、一部のケース(縦書き)でうまく動かないことがあったので回避策をメモしておきます。
ダミーの DOM を挿入する
わりと古典的でよく使われている方法です。caret のある位置にダミーの DOM を挿入して getBoundingClientRect
で位置を取得するというもの。どんな場面でもほぼ確実に caret の位置が取得できます。ただ、大きな弱点があり DOM を挿入するので対象 DOM の TextNode が分割されてしまいます。contenteditable を使ってゴリゴリ開発をする場合、むやみに Node が変わるとつらすぎるので、あまり使うメリットはありません。
<p contenteditable="true">click me</p>
<script>
document.onselectionchange = () => {
const range = window.getSelection().getRangeAt(0)
const anchor = document.createElement('span')
anchor.innerHTML = '​'
range.insertNode(anchor)
const pos = anchor.getBoundingClientRect()
anchor.parentElement.removeChild(anchor)
console.log(pos)
}
</script>
Range オブジェクトの座標を取得する
caret 位置は Range オブジェクトで知ることができます。つまり単純に Range にたいして getBoundingClientRect
を取得すれば caret の位置は取得できちゃいます。非常に簡単。通常はこちらを利用するのが良いと思います。
<p contenteditable="true">click me</p>
<script>
document.onselectionchange = () => {
const pos = window.getSelection().getRangeAt(0).getBoundingClientRect()
console.log(pos)
}
</script>
writing-mode: vertical-lr
では機能しない
しかし、冒頭に書いた一部のケースでうまく動かないというのが縦書きです。CSS で writing-mode: vertical-lr
にしている要素に対しては Chrome / Safari では上記の Range オブジェクトで getBoundingClientRect
が正常に取得できません。すべての値が 0
で返ってきます。Firefox は正常に返ってくるので…バグのような気がしなくもないです。
<style>
p{ writing-mode: vertical-lr; }
</style>
<p contenteditable="true">click me</p>
<script>
document.onselectionchange = () => {
const pos = window.getSelection().getRangeAt(0).getBoundingClientRect()
console.log(pos) // すべて 0 になる…
}
</script>
そこで回避策として以下のように Range オブジェクトを clone して選択範囲を指定し直すと、なぜか取得できるようになります。こちらでは正常に取得できる理由は分かりませんが…とりあえずこれで縦書きでも caret 位置を取得することができるようになりました。
<style>
p{ writing-mode: vertical-lr; }
</style>
<p contenteditable="true">click me</p>
<script>
document.onselectionchange = () => {
const range = window.getSelection().getRangeAt(0)
const clone = range.cloneRange()
const fixedPosition = range.endOffset
// 末尾の文字列を選択した時はダミーテキストを追加して選択範囲を拡大する
if (fixedPosition + 1 > range.endContainer.length) {
const dummy = document.createTextNode('​')
clone.insertNode(dummy)
clone.selectNode(dummy)
const rect = clone.getBoundingClientRect();
console.log(rect);
dummy.parentNode.removeChild(dummy)
} else {
clone.setStart(range.endContainer, fixedPosition);
clone.setEnd(range.endContainer, fixedPosition + 1);
const rect = clone.getBoundingClientRect();
console.log(rect);
}
clone.detach()
}
</script>