contenteditableについてのメモ書き17選
この記事はアドカレに参加しています。
この記事の話はすべてedgeでの話です。他のブラウザの挙動も大体同じだとは思いますが、思うだけです。すみませぬ。
contenteditable
contenteditableというものを知っていますか?
textareaではプレーンテキストを扱うことができますが、contenteditableな要素では様々な種類のNodeを扱うことができます。
色付のテキストから、図形、画像、動画まで、いろいろ扱える高機能なもの、それがcontenteditableな要素です。Twitter(X)のツイート画面がイメージしやすいかもしれません。
contenteditableについてのメモ
contenteditableについてのメモをつらつら紹介していきます。
僕はtextareaで特定文字にハイライト表示するようなもの(リッチテキストエディタ)を目指していたので、それ前提のものが多いです。さきにこの記事に目を通しておくといいかもしれません。
1. range書き換えの注意その1
EnterやBackspaceが押された際のイベントでrangeを書き換えると、caret位置がリセットされてしまいます。この記事によると、ブラウザ側の処理と重なるかららしいです。
つまり、EnterやBackspaceが押された際にrangeを動かしたい場合は、自力でEnterやBackspace相当の処理を書く必要があります。
editor_ele.addEventListener("keydown", (event) => {
  if (event.key === "Enter") {
    event.preventDefault()
    //ここで"Enter"相当の処理を行う
    //preventDefaultしているのでrangeを動かしても大丈夫
  }
})
2. range書き換えの注意その2
Enterやbackspaceが押された後、制御キーが押された場合でもrangeを書き換えると同様にcaret位置がリセットされます。
3. Shift + Enter
Shift + EnterとEnterとでは挙動が異なります。
caret位置を|として、以下のようにcontenteditableな要素があったとします。
<div contenteditable="true">
  <div>a|</div>
  <div>b</div>
  <div>c</div>
</div>
Enterを押した場合、新しいdiv要素(行)が挿入されます。
<div contenteditable="true">
  <div>a</div>
  <div><br>|</div> <!--new-->
  <div>b</div>
  <div>c</div>
</div>
Shift + Enterの場合、caret位置に直接\nが挿入されます。
<div contenteditable="true">
  <div>a<br>|</div>
  <div>b</div>
  <div>c</div>
</div>
Shift + EnterとEnterで同じ処理をしたい場合は、自力でその機能を実装する必要があります。
editor_ele.addEventListener("keydown", (event) => {
  if (event.key === "Enter") {
    event.preventDefault()
    //ここで"Enter"相当の処理を行う
  }
})
4. 入れ子タグの罠
spanタグなどで要素をハイライト表示したい場合などがあります。それが上手くいって、下のようなDOM構造になったとします。|はcaret位置です。
<div contenteditable="true">
  <div>a</div>
  <div>b</div>
  <div><span>test|</span></div>
</div>
このとき、Backspaceを長押しするとどうなると思いますか?
まずはtが消えますね?
<div contenteditable="true">
  <div>a</div>
  <div>b</div>
  <div><span>tes|</span></div>
</div>
次にsが消えます。
<div contenteditable="true">
  <div>a</div>
  <div>b</div>
  <div><span>te|</span></div>
</div>
eとtも消えたとしましょう。
三行目は<div><span></span></div>となるので、空になります。こういうとき、contenteditableな要素は自動で<br>タグを入れます。
<div contenteditable="true">
  <div>a</div>
  <div>b</div>
  <div><span><br>|</span></div>
</div>
さて、まだまだBackspaceを押します。
<div contenteditable="true">
  <div>a</div>
  <div>b</div>
  <div><span><br>|</span></div>
</div>
押します。
<div contenteditable="true">
  <div>a</div>
  <div>b</div>
  <div><span><br>|</span></div>
</div>
押し続けます。
<div contenteditable="true">
  <div>a</div>
  <div>b</div>
  <div><span><br>|</span></div>
</div>
押しているのに…
<div contenteditable="true">
  <div>a</div>
  <div>b</div>
  <div><span><br>|</span></div>
</div>
なんでだよ!
空行でBackspaceしているので、以下のようなDOM構造になるのが理想です。
<!--理想形-->
<div contenteditable="true">
  <div>a</div>
  <div>b|</div>
</div>
ですが、そうなりません。
<!--現実-->
<div contenteditable="true">
  <div>a</div>
  <div>b</div>
  <div><span><br>|</span></div>
</div>
これを防ぐには、Backspaceを自前で実装する必要があります。
editor_ele.addEventListener("keydown", (event) => {
  if (event.key === "Backspace") {
    event.preventDefault()
    //ここで"Backspace"相当の処理を行う
  }
})
5. 上下矢印キー
spanタグなどでハイライト表示している場合、上下矢印キーはほぼ役に立ちません。
下矢印キーを押した場合、caretは下の行に移動してほしいです。
<!--理想-->
<div contenteditable="true"><!--押す前-->
  <div>a</div>
  <div><span><br>te|st</span></div>
  <div>b</div>
</div>
↓
<div contenteditable="true"><!--押した後-->
  <div>a</div>
  <div><span><br>test</span></div>
  <div>b|</div>
</div>
<!--現実-->
<div contenteditable="true"><!--押す前-->
  <div>a</div>
  <div><span><br>te|st</span></div>
  <div>b</div>
</div>
↓
<div contenteditable="true"><!--押した後-->
  <div>a</div>
  <div><span><br>test|</span></div>
  <div>b</div>
</div>
上下矢印キーも自分で書き直す必要があります。
editor_ele.addEventListener("keydown", (event) => {
  if (event.key === "ArrowUp") {
    event.preventDefault()
    //ここでcaretを上に移動させる
  }
  else if (event.key === "ArrowDown") {
    event.preventDefault()
    //ここでcaretを下に移動させる
  }
})
6. Tab
規定の処理では、Tabを押しても\tは挿入されません。それどころか、Tabを押すとフォーカスが移動していきます。そのため、Tab機能を実装する必要があります。自前で。
editor_ele.addEventListener("keydown", (event) => {
  if (event.key === "Tab") {
    event.preventDefault()
    //ここでcaret位置にTabを挿入
  }
})
7. focus
普通にキーを入力するだけなら勝手にfocusされるので問題ありません。しかし、Tabのように自前でキー入力を実装する場合はfocusも自分で実装する必要があります。scrollIntoViewを使用すると楽です。
function editor_focus() {
  let range = window.getSelection().getRangeAt(0)
  let co = range.endContainer
  if (co.nodeName === "BR") {
    co.scrollIntoView({ behavior: "instant", block: "nearest", inline: "nearest" })
  }
  else {
    let obj = document.createElement("span")
    range.insertNode(obj)
    obj.scrollIntoView({ behavior: "instant", block: "nearest", inline: "nearest" })
    obj.parentElement.removeChild(obj)
  }
}
editor_ele.addEventListener("keydown", (event) => {
  if (event.key === "Tab") {
    event.preventDefault()
    //ここでcaret位置にTabを挿入
    editor_focus()//Tab位置にfocus
  }
})
8. pasteイベント
contenteditableな要素は画像なども貼り付けることができてしまいます。他のwebページのテキストをコピペする場合でも、他のwebページのcssスタイルが適用されたまま貼り付けられます。
これを防ぐためには、pasteイベント時の処理を自分で書きます。
editor_ele.addEventListener("paste", (event) => {
  //デフォルトの挙動を止める
  event.preventDefault()
  //プレーンテキストだけを取り出す
  let str = event.clipboardData.getData("text/plain")
  //改行コードを統一する
  let text = change_newline(str)
  
  //text文字列を挿入する処理を書く
})
function change_newline(str) {
  return str
    .replace(/(\r\n|¥r¥n)/gu, "\n")
    .replace(/(\r|¥n|¥r)/gu, "\n")
}
event.clipboardData.getData("text/plain")でくる文字列の改行コードは6種類考えられます。普通のバックスラッシュで、\n、\r、\r\nと、円マークで、¥n、¥r、¥r¥nです。面倒なので全部\nに統一してしまいましょう。
9. dropイベント
pasteイベントと同様に、dropイベントもプレーンテキストのみを受け付けられるようにします。
editor_ele.addEventListener("drop", (event) => {
  event.preventDefault()
  let str = event.dataTransfer.getData("text/plain")
  let text = this.change_newline(str)
  //text文字列を挿入する処理を書く
})
また、同じページ内のものがdropされると、dropされたものが消えます。
<!--drop前-->
<!--同じページの要素-->
<p>sample</p>
<!--contenteditableな要素-->
<div contenteditable="true">
  <div>a</div>
  <div>b</div>
</div>
<!--drop後-->
<!--同じページの要素-->
<!--contenteditableな要素-->
<div contenteditable="true">
  <div>a</div>
  <div>b<p>sample</p></div><!--dropされた場所に移動してしまう-->
</div>
そのため、同じページ内でdropしてほしくないものにはcssでuser-select:none;を付けます。
p {
  user-select:none;
  -webkit-user-select:none;
  -ms-user-select: none;
  -moz-user-select: none;
  -khtml-user-select: none;
  -webkit-user-drag: none;
  -khtml-user-drag: none;
}
10. range
rangeの注意点です。rangeについての仕様はこちらの記事が分かり易いです。
以下のようなDOM構造があったとします。|はcaret位置です。
<div id="editor" contenteditable="true">
  <div>a</div>|
  <div>b</div>
</div>
この場合は、startContainerとendContainerはidがeditorのNodeになります。Offsetは1です。
次のような場所にcaretがある場合はどうでしょう?
<div id="editor" contenteditable="true">
  <div>a</div>
  |<div>b</div>
</div>
この場合も、startContainerとendContainerはidがeditorのNodeになります。Offsetは1です。
つまり、rangeが同じでもcaretの位置は違う場合があるということです。そのため、caretは子ノードに入れるのが望ましいです。
<div id="editor" contenteditable="true">
  <div>a|</div><!--text nodeのoffset 1-->
  <div>b</div>
</div>
<div id="editor" contenteditable="true">
  <div>a</div>
  <div>|b</div><!--text nodeのoffset 0-->
</div>
11. range.deleteContents
範囲選択した状態でrange.deleteContentsすると、妙な空行ができてしまいます。そのため、この空行は自分で削除する必要があります。
<!--range.deleteContents前-->
<div id="editor" contenteditable="true">
  <div>a</div>
  <div>te|st</div>
  <div>test|</div>
  <div>d</div>
</div>
<!--range.deleteContents後-->
<div id="editor" contenteditable="true">
  <div>a</div>
  <div>te</div>
  <div>|</div><!--意図しない空行-->
  <div>d</div>
</div>
12. 行が1行もない場合
まずは一行だけある場合を考えます。
<div id="editor" contenteditable="true">
  <div><br>|</div>
</div>
ここでBackspaceすると、行が消えます。
<div id="editor" contenteditable="true">|</div>
さて、次にaキーを押してみます。
<div id="editor" contenteditable="true">a|</div>
はい、id="editor"に直接テキストノードが挿入されてしまいました。
これを防ぐためには、行が必ず存在するように実装する必要があります。
MutationObserverを使用して、以下のようにします。
let observer = new MutationObserver(() => {
  //行エレメントの有無を確認
  //行がなければ、空行を追加する
})
observer.observe(editor_ele, { childList: true, attributes: false, subtree: false })
13. 行番号表示
cssで行毎に番号を表示することができます。とても便利です。
.editor {
  counter-reset: line;
}
.editor pre::before {/*行エレメント*/
  content: counter(line);
  counter-increment: line;
  position: absolute;
  right: calc(100% + var(--font-size));
}
14. スクロールバーの模様替え
contenteditableな要素で文字を打ち込んでいるときに、長文化していくかもしれません。そんなときに、スクロールバーがあると便利です。
.editor {
  overflow: auto;
}
スクロールバーのデザインをcssで変えるには、以下のようにします。
.editor {
  /*Firefox*/
  scrollbar-width: thin;
  scrollbar-color: var(--scrollbar-color1) var(--scrollbar-color2);
  /*Chrome, Edge, and Safari*/
  &::-webkit-scrollbar {
    width: 10px;
    height: 10px;
  }
  &::-webkit-scrollbar-track {background: var(--scrollbar-color2);}
  &::-webkit-scrollbar-corner {background: var(--scrollbar-color2);}
  &::-webkit-scrollbar-thumb {
    background-color: var(--scrollbar-color1);
    border-radius: 20px;
    border: 3px solid var(--scrollbar-color2);
  }
}
15. 重い
contenteditableな要素に限った話ではありませんが、Nodeの数が増えれば増えるほど動作は重くなっていきます。対策としては、Qiitaにこんな記事があるのでこれを参考にします。見えない部分はdisplay:noneにしておきましょう、ということです。
16. 空行の罠
<div id="editor" contenteditable="true">
  <div><br></div>//1
  <div></div>//2
</div>
空行を表現する方法は上記の二通りが考えられます。一見、2のほうがシンプルでよいように見えます。ですが、2ではコピー時に改行のない文字列としてコピーされてしまいます。正しくテキストをコピーできるようにするには、コピーイベントをラップするか、1のようにbrタグを入れる必要があります。
17. innerTextとコピーされたテキストは違う
以下のようなDOM構造を考えます。
<div id="editor" contenteditable="true">
  <div>text</div>
  <div><br></div>
  <div>text</div>
</div>
上記のDOM構造で表示された文字列をコピーしてみると、text\n\ntextとなります。
innerTextはtext\n\n\ntextになります。改行の数が違いますね。
<div>text</div>のとき、innerTextはtext\nとなります。
<div><br></div>のとき、innerTextは\n\nとなります。
最終行が<div>text</div>のとき、innerTextはtextとなります。
最終行が<div><br></div>のとき、innerTextは\nとなります。
プログラム
LaTeX用のエディタアプリを作った際に、contenteditableを使用しました。この記事で紹介したすべてのものを実践している訳ではありませんが、editor.jsのあたりは参考になるかと思います。
https://github.com/metaphysical-bard/mathjax-editor
参考文献
・MINIMAL CODE EDITOR IN JAVASCRIPT
・JavaScitptでエディターを作ってみた感想とか
・【JavaScript】範囲(Range)について図解で理解する。
・DOM、Node、Elementを理解する
・ContentEditableの手懐け方
・contenteditableでTAB文字を入力可能にする方法
・textarea内でTabキーでインデントできるようにするjs(複数行対応)
・textarea内でタブを入力可能にする。ついでにタブ幅も変更する。
・■テキストエリア内にタブを入力する
・contenteditableな要素へのペースト時にスタイルを削除する方法
・JavaScript: 改行をすべて削除する
・[JavaScript]日本語IME入力中か判定する方法
・使ってみよう!MutationObserver!
・contentEditableな要素で文字入力とカーソル移動が遅い時の処方箋
・contenteditable
・CSS カウンターの使用
・整形済みテキスト要素
・Entity (エンティティ)
・KeyboardEvent: isComposing property
・Element: scrollIntoView() メソッド
・MutationObserver
むすび
contenteditable愛好家(?)のお力になれれば幸いです。
僕もよく分かっていないところが沢山あるので、間違い等ありましたらコメントで教えて頂けると助かります。
 
 
bsky
 
 
