WYSIWYGなMarkdownエディターを目指してContentEditableおよびexecCommandと真っ向勝負してみる(part 1)

今更ながらマークダウン記法について学び、とても感動しています。
最近はメモや簡単な文章作成にはマークダウンを使っています。

そんな時、自分の理想的なマークダウンエディタがブラウザ上で動けばもっと便利になると思い、少し試してみました。

修正点やもっとこうした方がいいなどございましたらご教授いただけると幸いです。m(_ _)m

# どんなエディター?

僕がイメージしているエディターは、よくある画面が二分割されてエディター部分とプレビュー部分に分かれているタイプでは無く、書いた部分がそのままHTMLに変換されるものをいいます。
有名なソフトだと、`Typora`などがあるでしょうか。

ざっと調べてもいろいろな実現方法があるみたいですが、今回は一番単純なようで実は大変そうな`contentEditable`と`execCommand`を用いた実装について試行錯誤しながら試してみました。

# contentEditableの仕様

実装に当たってまずは`contentEditable`の仕様を調べてみます。
以下のファイルでテストをしてみました。

``` contenteditable-test.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Test</title>
</head>
<body>
  <div contenteditable='true'>
    <h1>h1</h1>
    <h2>h2</h2>
    <div>div</div>
    <ul>
      <li>1</li>
      <li>2</li>
      <li>3</li>
    </ul>
    <blockquote>block quote</blockquote>
  </div>
</body>
</html>
```
マークダウン記法でいうと、文頭に所定のマークを入れる要素たちです。
とりあえず[ここ](https://tom-ose.github.io/qiita/mde/contenteditable-test.html)でこのページの簡単な挙動を調べることができます。

実際にお試しいただけるとわかりやすいかと思いますが、特徴をまとめるとこんな感じです。
なお、僕の環境では最新版のChromeをMac上で使っています。

**良さそうな点**

- `Shift+Enter`では同じエレメント内で改行、`Enter`のみだと今のエレメントの次に`div`が挿入される
- リストを`Enter`のみで改行すると次の行にもリストが自動で挿入される。二度`Enter`を押すと通常の`div`が挿入される

**少し気になった点**

- `blockquote`では`Enter`のみを押しても、`div`の代わりに`blockquote`が挿入され抜け出せない。

こんな感じでしょうか。
意外と標準機能でマークダウンエディターっぽい動きができている気がします。
あとはマークダウン記法を読み取って、その都度DOMを置き換えればいいような気がしていました。

この時までは...

# とりあえず作ってみた
## マークダウン記法の判定

さて、先述の特徴を踏まえて、文頭数文字で判断できる系の以下の四つを実装してみます。

- 見出し：#, ##, ###, ...など
- リスト：- 
- 番号付きリスト：1.
- 引用文：>

キャレットのある位置行の文字は以下のコードで取得できます

``` javascript
window.getSelection().getRangeAt(0).endContainer.data
```

`keyup`イベントが起こるたびに現在の行の文字列を取得し、マークダウン記法になっているかを判定します。

``` javascript
const element = document.getElementById('markdown')

element.addEventListener('keyup', function (event) {
  const currentLine = window.getSelection().getRangeAt(0).endContainer.data

  if(currentLine.match(/^#{1}\xA0$/)){ // 見出し
    // '# 'を<h1>に置き換える
  } 
  //
  // 見出し2〜6は{}内の数字を変えるだけ
  //
    else if (currentLine.match(/^>\xA0$/)){ // 引用
    // '> 'を<blockquote>に置き換える
  } else if (currentLine.match(/^\d+\.\xA0$/)) { // 順序付きリスト
    // '- 'を<ul>に置き換える
  } else if (currentLine.match(/^[\-+*]\xA0+$/)) { // リスト
    // '1. 'を<ol>に置き換える
  }
})
```

はい、これで場合分けができました。

## マークダウン記法の変換
`contentEditable`では`Enter`を押すと改行されて`div`が挿入されます。
この`div`をマークダウン記法に対応するDOMに変更するために`execCommand`を用います。

以下は＜h1＞に変換する例です。

``` javascript
document.execCommand('formatblock', false, 'h1')
```

第一引数の`formatblock`は[MDN web docs](https://developer.mozilla.org/ja/docs/Web/API/Document/execCommand)によると、

> **formatBlock**
現在の選択範囲を含む行の前後に HTML ブロックレベル要素を追加し、すでに存在する場合は、その行を含むブロック要素に置き換えます (Firefox では \<blockquote> は例外です。 — これはブロック要素を囲みます)。引数としてタグ名の文字列が必要です。実質的にすべてのブロックレベル要素を利用することができます。 
(Internet Explorer および Edge は見出しタグ H1–H6, ADDRESS, PRE のみに対応しており、 "\<H1>" のように山かっこで囲む必要があります。)

とのことです。

要するに、今キャレットがあるDOMを指定の要素に変換するってことだと思います。
(詳しくはわからないので、ご教授いただけると幸いです。)

なお、リストと順序付きリストは別のコマンドを用います。
それぞれ以下のようになります。

``` javascript
// リスト
document.execCommand('insertUnorderedList')

// 順序付きリスト
document.execCommand('insertOrderedList')
```

## マークダウン記法の削除
さて、前述までの方法でマークダウン記法を判定して、HTML要素の変換がすることができましたが、文字として打ち込んだマークダウン記法は残ったままです。
これを削除します。

要素内のテキストを変更するといえば、`innerText`が思いつきます。
キャレットがある要素の`innerText`を取得することでうまくいきそうです。

先述した`window.getSelection().getRangeAt(0).endContainer`では現在のテキスト部分、つまり`#text`が取得されます。
この親要素が現在の要素になります。
すなわち、マークダウン記法を削除するには以下のコードを用います。

``` javascript
window.getSelection().getRangeAt(0).endContainer.parentNode.innerText = ''
```

# とりあえずできた

以上のことをまとめると以下のコードになりました。

``` javascript
const element = document.getElementById('markdown')

element.addEventListener('keyup', function (event) {
  const currentLine = window.getSelection().getRangeAt(0).endContainer.data

  if(currentLine.match(/^#{1}\xA0$/)){ // 見出し
      document.execCommand('formatblock', false, 'h1')
      clearCurrentLine()
  } 
  //
  // 見出し2〜6は{}内の数字を変えるだけ
  //
    else if (currentLine.match(/^>\xA0$/)){ // 引用
      document.execCommand('formatblock', false, 'blockquote')
      clearCurrentLine()
  } else if (currentLine.match(/^\d+\.\xA0$/)) { // 順序付きリスト
      document.execCommand('insertOrderedList')
      clearCurrentLine()
  } else if (currentLine.match(/^[\-+*]\xA0+$/)) { // リスト
      document.execCommand('insertUnorderedList')
      clearCurrentLine()
  }
})

const clearCurrentLine = () => {
  window.getSelection().getRangeAt(0).endContainer.parentNode.innerText = ''
}
```

デモは[こちら](https://tom-ose.github.io/qiita/mde/mde-part1.html)から

# 次回に向けての修正点

さて、デモをお触りいただくとわかると思いますが、このコードChromeではうまく動きませんorz
(Safariでは動きました。他のブラウザは未検討)
要素は挿入されますが、キャレットが直前の要素に飛んでしまい、しかも挿入された要素にはどうやってもキャレットを合わせることができません。

これを踏まえた以下が次回に向けての修正点です。

- 挿入された要素にキャレットを合わせることができない
- `blockquote`は`Enter`を押しても抜け出すことができない

次回はこれらの問題点を修正するところから始めます。
アドバイスなどございましたら、コメントください。m(_ _)m