Closed6

ContentEditableの手懐け方

tuskotusko

Solid JSでNotion風?のWYSIWYGなMarkdownエディタを作成しようと思い、contenteditableと奮闘した際の知見記録。

以下の検証は、全てGoogle Chrome(103.0.5060.114(Official Build))による。

tuskotusko

改行させてはいけない

改行時の挙動は予測不可能(なことはないけど)、扱いにくいのでやめよう。

改行時の挙動

<div contenteditable="true">Editable</div>

を改行すると

<div contenteditable="true">
  "Editable"
   <div>
     <br>
   </div>
</div>

のように統一感のなさが気持ち悪い。さらにタチの悪いことに、<p contenteditable="true">Editable</p>としても、<div><br></div>が挟まれる。

このままでは、非常に扱いにくいが、これは簡単に解決可能で

<div contenteditable="true">
  <p>Editable</p>
</div>

とすれば

<div contenteditable="true">
  <p>Editable</p>
  <p>
    <br>
  </p>
</div>

のような統一性のある改行挙動が生み出せる。タグ内で改行した場合は、そのタグが新しく生成されるらしい。

ところが、Backspaceを押すと?

<div contenteditable="true">
  <p>Editable</p>
</div>

に対して、Backspaceを押し続けるとcontenteditable内の要素は全て削除可能なので、

<div contenteditable="true"></div>

となってしまう。ここから文字入力をすると

<div contenteditable="true">Not Jesus</div>

のように最初の状況に戻ってしまう。

これらに対応するため、Enterキー入力をpreventして、次のようにscriptで新しいcontenteditable要素を

<div contenteditable="true">Editable</div>
<div contenteditable="true">Editable</div>

差し込むようにした。Backspaceでcontenteditable要素を削除するためのコードが増えるのが面倒だが、次のCaretの話とも関わる部分で、今一番安定している。

tuskotusko

Caret位置の取得のために、DOMはscriptでコントロールしよう

contenteditable利用時の最大の沼地はCaret位置の取得だと思う。

contenteditableにおいては、selectionをwindow.getSelection()などで取得することによって、Caret位置を取得するが、この挙動も扱いやすくない。

selectionの挙動

例えば、

<div id="editable" contenteditable="true">Editable</div>
<script>
  const handleInput = () => {
    console.log(window.getSelection())
  }

  const div = document.getElementById("editable")
  div.addEventListener('input', handleInput)
</script>

において、EditableEdiableとすると、

Selection {
  anchorNode: text
  anchorOffset: 3
  ...
  type: "Caret"
}

が返される。この例におけるanchorNode<div id="editable" contenteditable="true">Ediable</div>を指している

次に、

"Edi"
<div>
  table
</div>

と改行した後にEditableEdiableとしてみると、

Selection {
  anchorNode: text
  anchorOffset: 0
  ...
  type: "Caret"
}

この場合はanchorNode<div>able</div>を指している。

つまり、selectionで返される値は、Caretが現在位置するNodeの先頭から数えた数置である。

例えば、contenteditable要素の先頭からの位置が欲しくなった場合は、DOMを考慮して、現在のNodeまでの文字数を数え上げる必要が生じる。そのため、先のように新しい要素を生成させないように、Scriptで改行するのが便利。

tuskotusko

文字表示する要素を一括して検索できるように統一する

ここまでは、単純な状況におけるcontenteditableの制御方法の提案だったが、Markdownエディタとして、実際に用いる場合は

<p contentediatble="true">
  <strong>
    **Strong
    <strike>
      ~~Strike~~
    </strike>
    **
  </strong>
</p>

のように、タグを追加しなければならない。タグはネストできるため、DOMは必然的に複雑になり、上で述べたようにCaret取得が難しくなる。

<p contentediatble="true">
  <strong>
    <span class="text">**Strong</span>
    <strike>
      <span class="text">~~Strike~~</span>
    </strike>
    <span class="text">**</span>
  </strong>
</p>

のようにclass名を統一しておけば、querySelectorなどで、文字部分だけを一括して取得できる。あとはよしなに扱えばこの問題は解決する。フレームワークを用いている場合は、refのリストを作成しても上手くいくだろう。

tuskotusko

highlight syntaxライブラリとの共存

Markdownを用いる人の9割は、コードブロックを利用しそうなもので、見栄えのためにhighlight syntaxが欲しくなる人も多いだろう。

DOMを更新すると、Caretが飛ぶので、Caret位置を取得→更新→Caretを戻すという手順を踏まなければならないが、既成のhigligh syntaxライブラリを用いると、上に述べたようなDOM制御が行いにくい。

そこで、Caret表示以外は透明な記入部分と、highlight描画の部分を準備して、重ねてしまうという荒技を用いた。もっとスマートな技があるかもしれないが...

tuskotusko

ところで、contenteditableガチガチに制御してまで利用する旨味はあるのだろうか...?
Previewと並べるか、切り替えるかで執筆できるようにすればいいような気がしないでもない。

このスクラップは2022/07/08にクローズされました