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


この記事はpart2です。
part1をまだご覧になっていない方はそちらを先にお読みいただくと話の流れがわかりやすいかと思います。

前回残された修正点

前回の実装で残された修正点は以下のようになっています。

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

今回はこれらを修正していきます。

① 挿入した要素にキャレットを合わせられない問題について

前回の実装ではexecCommandでHTML要素を変更した後に、innerTextを空にしていました。
この際、キャレットが置き換えた要素の直前の要素に(なぜか)移動してしまうのが原因でこのような不具合が起こっています。
そして、置き換えた要素にはキャレットを合わせることはできません。

この問題を解決するために、要素を置き換えた後にinnerTextを空にするのではなく、スペースを入れることで対処してみました。
すなわち、

// 前回のコード
window.getSelection().getRangeAt(0).endContainer.parentNode.innerText = ''

// 修正
window.getSelection().getRangeAt(0).endContainer.parentNode.innerText = '\xA0'

どうにかキャレットを合わせることができました。
ですが、これによって新たな問題が出てきました。

上のGif画像の最後にちょっと映ってますが、改行した後に再びH1が挿入されています。
この問題は前回の二番目の問題である、無限blockquoteと同じようなものと捉え次の章で対処します。

また、ブラウザの標準機能として、リストを抜け出す際には空の行でEnterを押す必要があります。

1. aaa
2. |  ←キャレット位置

↓ Enter押下

1. aaa
|

ですが、先ほどの実装では行の末尾にスペースが入るため、行が空とはみなされず改行しても延々とリストのままです。
つまり、リストや順序付きリストを挿入する際にはスペースを挿入する必要はなさそうです。
前回の実装でマークダウン記法の場合分けをしましたが、リストや順序付きリストの時には前回同様innerTextを空にするようにします。

② 無限blockquoteについて

例によって詳しい原因は不明ですが、前回の実装ではblockquoteの中でEnterを押しても抜け出すことができませんでした。
原因がわからないため、改行で新たに挿入されたblockquoteexecCommandで無理やりdivに変換する実装をしました。
マークダウン記法を置き換えたのと逆のやり方です。

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

なお、Shift+Enterは同じ要素内での改行なので、ShiftなしのEnterのみを捕捉して上記のコマンドを実行する必要があります。
また、リストや順序付きリストはブラウザの標準機能で望ましい動きをするので、divに置き換える処理を適応しない必要があります。

Shiftの捕捉はキーイベントの.shiftKeyプロパティで入手できます。
リストの判定は、キャレットのある要素名を取得することでやりました。

上記をまとめると以下の処理になります。

if (!event.shiftKey && event.keyCode === 13) {
    if (!focusingOnOrderElement()) {
       document.execCommand('formatblock', false, 'div')
    }
} 

const focusingOnOrderElement = () => {
    const element_name = window.getSelection().getRangeAt(0).endContainer.parentNode.nodeName
    return (element_name === 'LI' || element_name === 'UL' || element_name === 'OL')
}

完成形

今回の改良点をまとめると以下のようになります。


const element = document.getElementById('markdown')
    element.focus()

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

      if (!event.shiftKey && event.keyCode === 13) {
         if (!focusingOnOrderElement()) {
          document.execCommand('formatblock', false, 'div')
        }
      }else{
        if (currentLine.match(/^#{1}\xA0$/)) { // 見出し
          document.execCommand('formatblock', false, 'h1')
          clearCurrentLine()
        } else if (currentLine.match(/^#{2}\xA0$/)){
          document.execCommand('formatblock', false, 'h2')
          clearCurrentLine()
        } else if (currentLine.match(/^#{3}\xA0$/)) {
          document.execCommand('formatblock', false, 'h3')
          clearCurrentLine()
        } else if (currentLine.match(/^#{4}\xA0$/)) {
          document.execCommand('formatblock', false, 'h4')
          clearCurrentLine()
        } else if (currentLine.match(/^#{5}\xA0$/)) {
          document.execCommand('formatblock', false, 'h5')
          clearCurrentLine()
        } else if (currentLine.match(/^#{6}\xA0$/)) {
          document.execCommand('formatblock', false, 'h6')
          clearCurrentLine()
        } 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 = (clearCharacter = '\xA0') => {
      window.getSelection().getRangeAt(0).endContainer.parentNode.innerText = clearCharacter
    }

    const focusingOnOrderElement = () => {
      const element_name = window.getSelection().getRangeAt(0).endContainer.parentNode.nodeName
      return (element_name === 'LI' || element_name === 'UL' || element_name === 'OL')
    }

デモはこちらです。

次回に向けて

今回の実装でChrome上でもなんとか動くようになりました(SafariとChromeのみで試しています)
次回以降は強調イタリックなどの文頭ではないマークダウン記法の判定をできればと思います。

乱文でしたが、最後までお読みいただきありがとうございましたm(_ _)m
改善点などございましたら、ご教授願います。