8
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2019-08-30

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

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

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

どんなエディター?

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

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

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>

マークダウン記法でいうと、文頭に所定のマークを入れる要素たちです。
とりあえずここでこのページの簡単な挙動を調べることができます。

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

良さそうな点

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

少し気になった点

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

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

この時までは...

とりあえず作ってみた

マークダウン記法の判定

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

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

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

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

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

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>に変換する例です。

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

第一引数のformatblockMDN web docsによると、

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

とのことです。

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

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

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

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

マークダウン記法の削除

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

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

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

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

とりあえずできた

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

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 = ''
}

デモはこちらから

次回に向けての修正点

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

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

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

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

8
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?