contenteditableについてのメモ書き17選

# contenteditableについてのメモ書き17選

この記事は[アドカレ](https://qiita.com/advent-calendar/2023/hachinohe_kosen)に参加しています。

:::note warn
この記事の話はすべてedgeでの話です。他のブラウザの挙動も大体同じだとは思いますが、思うだけです。すみませぬ。
:::

## contenteditable
[contenteditable](https://developer.mozilla.org/ja/docs/Web/HTML/Global_attributes/contenteditable)というものを知っていますか？
[textarea](https://developer.mozilla.org/ja/docs/Web/HTML/Element/textarea)ではプレーンテキストを扱うことができますが、contenteditableな要素では様々な種類のNodeを扱うことができます。

色付のテキストから、図形、画像、動画まで、いろいろ扱える高機能なもの、それがcontenteditableな要素です。Twitter(X)のツイート画面がイメージしやすいかもしれません。


## contenteditableについてのメモ
contenteditableについてのメモをつらつら紹介していきます。

僕はtextareaで特定文字にハイライト表示するようなもの(リッチテキストエディタ)を目指していたので、それ前提のものが多いです。さきに[この記事](https://zserge.com/posts/js-editor/)に目を通しておくといいかもしれません。

### 1. range書き換えの注意その1
`Enter`や`Backspace`が押された際のイベントで`range`を書き換えると、caret位置がリセットされてしまいます。[この記事](https://zserge.com/posts/js-editor/)によると、ブラウザ側の処理と重なるかららしいです。

つまり、`Enter`や`Backspace`が押された際に`range`を動かしたい場合は、自力で`Enter`や`Backspace`相当の処理を書く必要があります。
```javascript
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な要素があったとします。
```html
<div contenteditable="true">
  <div>a|</div>
  <div>b</div>
  <div>c</div>
</div>
```
`Enter`を押した場合、新しいdiv要素(行)が挿入されます。
```html
<div contenteditable="true">
  <div>a</div>
  <div><br>|</div> <!--new-->
  <div>b</div>
  <div>c</div>
</div>
```
`Shift + Enter`の場合、caret位置に直接`\n`が挿入されます。
```html
<div contenteditable="true">
  <div>a<br>|</div>
  <div>b</div>
  <div>c</div>
</div>
```

`Shift + Enter`と`Enter`で同じ処理をしたい場合は、自力でその機能を実装する必要があります。
```javascript
editor_ele.addEventListener("keydown", (event) => {
  if (event.key === "Enter") {
    event.preventDefault()
    //ここで"Enter"相当の処理を行う
  }
})
```

### 4. 入れ子タグの罠
spanタグなどで要素をハイライト表示したい場合などがあります。それが上手くいって、下のようなDOM構造になったとします。`|`はcaret位置です。
```html
<div contenteditable="true">
  <div>a</div>
  <div>b</div>
  <div><span>test|</span></div>
</div>
```
このとき、`Backspace`を長押しするとどうなると思いますか？
まずは`t`が消えますね？
```html
<div contenteditable="true">
  <div>a</div>
  <div>b</div>
  <div><span>tes|</span></div>
</div>
```
次に`s`が消えます。
```html
<div contenteditable="true">
  <div>a</div>
  <div>b</div>
  <div><span>te|</span></div>
</div>
```
`e`と`t`も消えたとしましょう。
三行目は`<div><span></span></div>`となるので、空になります。こういうとき、contenteditableな要素は自動で`<br>`タグを入れます。
```html
<div contenteditable="true">
  <div>a</div>
  <div>b</div>
  <div><span><br>|</span></div>
</div>
```
さて、まだまだ`Backspace`を押します。
```html
<div contenteditable="true">
  <div>a</div>
  <div>b</div>
  <div><span><br>|</span></div>
</div>
```
押します。
```html
<div contenteditable="true">
  <div>a</div>
  <div>b</div>
  <div><span><br>|</span></div>
</div>
```
押し続けます。
```html
<div contenteditable="true">
  <div>a</div>
  <div>b</div>
  <div><span><br>|</span></div>
</div>
```
押しているのに…
```html
<div contenteditable="true">
  <div>a</div>
  <div>b</div>
  <div><span><br>|</span></div>
</div>
```
~~なんでだよ！~~

空行で`Backspace`しているので、以下のようなDOM構造になるのが理想です。
```html
<!--理想形-->
<div contenteditable="true">
  <div>a</div>
  <div>b|</div>
</div>
```
ですが、そうなりません。
```html
<!--現実-->
<div contenteditable="true">
  <div>a</div>
  <div>b</div>
  <div><span><br>|</span></div>
</div>
```
これを防ぐには、`Backspace`を自前で実装する必要があります。
```javascript
editor_ele.addEventListener("keydown", (event) => {
  if (event.key === "Backspace") {
    event.preventDefault()
    //ここで"Backspace"相当の処理を行う
  }
})
```

### 5. 上下矢印キー
spanタグなどでハイライト表示している場合、上下矢印キーはほぼ役に立ちません。

下矢印キーを押した場合、caretは下の行に移動してほしいです。
```html
<!--理想-->
<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>
```
```html
<!--現実-->
<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>
```

上下矢印キーも自分で書き直す必要があります。
```javascript
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`機能を実装する必要があります。自前で。
```javascript
editor_ele.addEventListener("keydown", (event) => {
  if (event.key === "Tab") {
    event.preventDefault()
    //ここでcaret位置にTabを挿入
  }
})
```

### 7. focus
普通にキーを入力するだけなら勝手にfocusされるので問題ありません。しかし、`Tab`のように自前でキー入力を実装する場合はfocusも自分で実装する必要があります。`scrollIntoView`を使用すると楽です。
```javascript
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イベント時の処理を自分で書きます。
```javascript
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`イベントもプレーンテキストのみを受け付けられるようにします。
```javascript
editor_ele.addEventListener("drop", (event) => {
  event.preventDefault()
  let str = event.dataTransfer.getData("text/plain")
  let text = this.change_newline(str)
  //text文字列を挿入する処理を書く
})
```

また、同じページ内のものが`drop`されると、`drop`されたものが消えます。
```html
<!--drop前-->

<!--同じページの要素-->
<p>sample</p>

<!--contenteditableな要素-->
<div contenteditable="true">
  <div>a</div>
  <div>b</div>
</div>
```

```html
<!--drop後-->

<!--同じページの要素-->


<!--contenteditableな要素-->
<div contenteditable="true">
  <div>a</div>
  <div>b<p>sample</p></div><!--dropされた場所に移動してしまう-->
</div>
```


そのため、同じページ内で`drop`してほしくないものにはcssで`user-select:none;`を付けます。
```css
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についての仕様は[こちらの記事](https://qiita.com/sho_U/items/088d15d38176b82dce00)が分かり易いです。

以下のようなDOM構造があったとします。`|`はcaret位置です。
```html
<div id="editor" contenteditable="true">
  <div>a</div>|
  <div>b</div>
</div>
```
この場合は、`startContainer`と`endContainer`は`id`が`editor`のNodeになります。`Offset`は1です。

次のような場所にcaretがある場合はどうでしょう？
```html
<div id="editor" contenteditable="true">
  <div>a</div>
  |<div>b</div>
</div>
```
この場合も、`startContainer`と`endContainer`は`id`が`editor`のNodeになります。`Offset`は1です。

つまり、`range`が同じでもcaretの位置は違う場合があるということです。そのため、caretは子ノードに入れるのが望ましいです。
```html
<div id="editor" contenteditable="true">
  <div>a|</div><!--text nodeのoffset 1-->
  <div>b</div>
</div>
```
```html
<div id="editor" contenteditable="true">
  <div>a</div>
  <div>|b</div><!--text nodeのoffset 0-->
</div>
```

### 11. range.deleteContents
範囲選択した状態で`range.deleteContents`すると、妙な空行ができてしまいます。そのため、この空行は自分で削除する必要があります。
```html
<!--range.deleteContents前-->
<div id="editor" contenteditable="true">
  <div>a</div>
  <div>te|st</div>
  <div>test|</div>
  <div>d</div>
</div>
```
```html
<!--range.deleteContents後-->
<div id="editor" contenteditable="true">
  <div>a</div>
  <div>te</div>
  <div>|</div><!--意図しない空行-->
  <div>d</div>
</div>
```

### 12. 行が１行もない場合
まずは一行だけある場合を考えます。
```html
<div id="editor" contenteditable="true">
  <div><br>|</div>
</div>
```
ここで`Backspace`すると、行が消えます。
```html
<div id="editor" contenteditable="true">|</div>
```
さて、次に`a`キーを押してみます。
```html
<div id="editor" contenteditable="true">a|</div>
```
はい、`id="editor"`に直接テキストノードが挿入されてしまいました。

これを防ぐためには、行が必ず存在するように実装する必要があります。
[MutationObserver](https://developer.mozilla.org/ja/docs/Web/API/MutationObserver)を使用して、以下のようにします。
```javascript
let observer = new MutationObserver(() => {
  //行エレメントの有無を確認
  //行がなければ、空行を追加する
})
observer.observe(editor_ele, { childList: true, attributes: false, subtree: false })
```

### 13. 行番号表示
cssで行毎に番号を表示することができます。とても便利です。
```css
.editor {
  counter-reset: line;
}

.editor pre::before {/*行エレメント*/
  content: counter(line);
  counter-increment: line;
  position: absolute;
  right: calc(100% + var(--font-size));
}
```

### 14. スクロールバーの模様替え
contenteditableな要素で文字を打ち込んでいるときに、長文化していくかもしれません。そんなときに、スクロールバーがあると便利です。
```css
.editor {
  overflow: auto;
}
```
スクロールバーのデザインをcssで変えるには、以下のようにします。
```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に[こんな記事](https://qiita.com/pochman/items/dab9965ecc89368b2b46)があるのでこれを参考にします。見えない部分は`display:none`にしておきましょう、ということです。

### 16. 空行の罠
```html
<div id="editor" contenteditable="true">
  <div><br></div>//1
  <div></div>//2
</div>
```
空行を表現する方法は上記の二通りが考えられます。一見、`2`のほうがシンプルでよいように見えます。ですが、`2`ではコピー時に改行のない文字列としてコピーされてしまいます。正しくテキストをコピーできるようにするには、コピーイベントをラップするか、`1`のように`br`タグを入れる必要があります。

### 17. innerTextとコピーされたテキストは違う
以下のようなDOM構造を考えます。
```html
<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](https://zserge.com/posts/js-editor/)
・[JavaScitptでエディターを作ってみた感想とか](https://qiita.com/tomomoss/items/859c6ac793f983a6ae7c)
・[【JavaScript】範囲(Range)について図解で理解する。](https://qiita.com/sho_U/items/088d15d38176b82dce00)
・[DOM、Node、Elementを理解する](https://qiita.com/andota05/items/a2292d2b7780ed5faa31)
・[ContentEditableの手懐け方](https://zenn.dev/tusko/scraps/fea1f92882153e)
・[contenteditableでTAB文字を入力可能にする方法](https://systemix.ne.jp/info/88)
・[textarea内でTabキーでインデントできるようにするjs（複数行対応）](https://qiita.com/laineus/items/12a220d2ab086931232d)
・[textarea内でタブを入力可能にする。ついでにタブ幅も変更する。](https://freefielder.jp/blog/2020/05/input-custon-sized-tab-in-textarea.html)
・[■テキストエリア内にタブを入力する](http://www.webclap-dandy.com/?category=Programing&id=5)
・[contenteditableな要素へのペースト時にスタイルを削除する方法](https://qiita.com/tashinoso/items/5519a5fa56e7585d8a93)
・[JavaScript: 改行をすべて削除する](https://step-learn.com/article/javascript/058-rn-delete.html)
・[[JavaScript]日本語IME入力中か判定する方法](https://qiita.com/alt_yamamoto/items/8663d047a3794dd5605e)
・[使ってみよう！MutationObserver！](https://qiita.com/ryo_hisano/items/9f15ae87d691d497bc17)
・[contentEditableな要素で文字入力とカーソル移動が遅い時の処方箋](https://qiita.com/pochman/items/dab9965ecc89368b2b46)
・[contenteditable](https://developer.mozilla.org/ja/docs/Web/HTML/Global_attributes/contenteditable)
・[CSS カウンターの使用](https://developer.mozilla.org/ja/docs/Web/CSS/CSS_Counter_Styles/Using_CSS_counters)
・[整形済みテキスト要素](https://developer.mozilla.org/ja/docs/Web/HTML/Element/pre)
・[Entity (エンティティ)](https://developer.mozilla.org/ja/docs/Glossary/Entity)
・[KeyboardEvent: isComposing property](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/isComposing)
・[Element: scrollIntoView() メソッド](https://developer.mozilla.org/ja/docs/Web/API/Element/scrollIntoView)
・[MutationObserver](https://developer.mozilla.org/ja/docs/Web/API/MutationObserver)

## むすび
contenteditable愛好家(?)のお力になれれば幸いです。
僕もよく分かっていないところが沢山あるので、間違い等ありましたらコメントで教えて頂けると助かります。
　
　
[bsky](https://bsky.app/profile/drywaterfall.bsky.social)