⚙️

ProseMirrorでキー入力時の内部処理を少し追ってみた

2024/12/22に公開

以前、興味本位でcontenteditableでリッチテキストエディター(RTE)を自作しましたが、世の中のRTEはどのようにキーボード入力を処理してるのか気になりました。
今回はProseMirrorというRTEで、キー入力時の内部処理を追ってみます。

ProseMirrorとは

ProseMirrorはリッチテキストエディターを実装するライブラリの1つです。
ドキュメントを最終的に得られる見た目のまま編集するWYSIWYGや、メンションや画像を使える少し工夫したエディタを開発することができます。
NotionやSlackの入力欄みたいなイメージです。

どこを見ればいい?

prosemirror-viewがDOM周りを担当していそうだったので、そこを見ていきます。
今回はキー入力なので、keydowninputのイベントハンドラを中心的に見ていけば良さそうです。

以下の箇所を中心に見ていきます。

  • キー入力は、ブラウザで制御 or ライブラリで制御 のどちらか?
  • Enterなどの制御文字はどう対応してる?
  • 入力を内部状態にはどう反映してる?

「a」を入力

調べたところ、大まかな概要は以下のようになリました。

  • 文字入力はブラウザデフォルトの挙動に任せている
  • MutationObserverでDOM変更を検知し、トランザクションを作成して内部状態を更新

以下詳細を見ていきます。

まず、イベント周りの処理はinput.tsに記載されていました。
「a」を入力すると、まずkeydownが実行されるはずです。
色々省くと以下のようなコードです。

editHandlers.keydown = (view: EditorView, _event: Event) => {
  let event = _event as KeyboardEvent
  if (inOrNearComposition(view, event)) return

  if (browser.ios && event.keyCode == 13 && !event.ctrlKey && !event.altKey && !event.metaKey) {
    /// ...
  } else if (view.someProp("handleKeyDown", f => f(view, event)) || captureKeyDown(view, event)) {
    event.preventDefault()
  } else {
    setSelectionOrigin(view, "key")
  }
}

ここで重要なのは、view.someProp("handleKeyDown", f => f(view, event)) || captureKeyDown(view, event)という条件分岐の箇所です。
まず、view.somePropは他のプラグインなどで登録されたhandleKeyDownのpropsを探し、1つずつ実行していきます。
trueが返された場合、即時にsomePropが終わって結果がtrueになります。

また、captureKeyDownは制御文字かどうかを判定しています。(capturekey.ts)

export function captureKeyDown(view: EditorView, event: KeyboardEvent) {
  let code = event.keyCode, mods = getMods(event)
  if (code == 8 || (browser.mac && code == 72 && mods == "c")) { // Backspace, Ctrl-h on Mac
    return stopNativeHorizontalDelete(view, -1) || skipIgnoredNodes(view, -1)
  } else if ((code == 46 && !event.shiftKey) || (browser.mac && code == 68 && mods == "c")) { // Delete, Ctrl-d on Mac
    return stopNativeHorizontalDelete(view, 1) || skipIgnoredNodes(view, 1)
  } else if (code == 13 || code == 27) { // Enter, Esc
    return true
  } ...
  return false
}

例えば、Enterを入力した場合はここでtrueが返されます。

今回は「a」を入力したので、view.someProp, captureKeyDownともにfalseで条件分岐が通らず、最後のelseが実行されそうです。

ここで重要なのは、preventDefaultがされないことです。つまり、keydownでは処理をブラウザに任せており、次のkeypress, beforeinput, input, keyupへと進みます。
逆に制御文字であるEnterはcaptureKeyDownがtrueを返すので、preventDefaultされています。こちらはライブラリ側で制御していることがわかります。

beforeinput, input, keyupには大した処理がありませんでしたが、keypressは気になる箇所が1つあったのでみておきます。

editHandlers.keypress = (view, _event) => {
  let event = _event as KeyboardEvent
  // ...

  let sel = view.state.selection
  if (!(sel instanceof TextSelection) || !sel.$from.sameParent(sel.$to)) {
    let text = String.fromCharCode(event.charCode)
    if (!/[\r\n]/.test(text) && !view.someProp("handleTextInput", f => f(view, sel.$from.pos, sel.$to.pos, text)))
      view.dispatch(view.state.tr.insertText(text).scrollIntoView())
    event.preventDefault()
  }
}

TextSelectionではない(主にNodeSelection)か、範囲が同じ親を指していないときに、条件分岐の中に入ります。恐らく、NodeSelectionや複数ノードを跨いだ選択時の入力は、ブラウザデフォルトの挙動で共通ではないから or カスタマイズしたいからだと思います。
今回は「a」なので、ここは無視して良さそうです。

以上を整理すると、「a」を入力するとブラウザデフォルトに任せてDOMに反映していました。

では、この入力をどう内部状態に適用しているのでしょう?
答えはMutationObserverを使っています。処理はdomoverser.tsにありました。

簡単に説明すると、MutationObserverのフックが変更に反応して実行され、flushメソッドが呼び出されます。
その中で変更を反映したトランザクションを生成し、view.updateState(view.state)が実行され内部状態が更新されています。

「Enter」を入力

概要はこんな感じです。

  • Enterなどの制御文字は、keydown時に弾いている
  • keybindでhandleKeyDownのpropsに、ショートカットを追加することで対応

Enterを入力すると、初期状態ではkeydowncatureKeyDownがtrueを返し、何も起こりません。
これは、ProseMirrorを最低限の機能で動かした挙動と一致しています。

では、Enterをkeybindするとどうなるのでしょうか?

Enterの基本処理は、prosemirror-commandsにあります。

export const pcBaseKeymap: {[key: string]: Command} = {
  "Enter": chainCommands(newlineInCode, createParagraphNear, liftEmptyBlock, splitBlock),
// ...

この中で比較的よく実行されるのが、splitBlockです。
splitBlockは、ブロック中でEnterを押した際に、そこを起点にブロックを分割します。よくあるRTE内での改行がこの処理です。
この中で、トランザクションが作られ内部状態の更新・DOMへの反映が行われています。

感想

内部コードにはブラウザ依存の処理が大量に記載されており、RTE実装の苦労がひしひしと感じられました。
また、思ったよりもブラウザの処理をそのまま使っているところが多かったのが意外でした。
トランザクションをDOMに反映するあたりのコードは複雑だったので、余裕がある時に読みたいです。

Discussion