ProseMirrorでキー入力時の内部処理を少し追ってみた
以前、興味本位でcontenteditableでリッチテキストエディター(RTE)を自作しましたが、世の中のRTEはどのようにキーボード入力を処理してるのか気になりました。
今回はProseMirrorというRTEで、キー入力時の内部処理を追ってみます。
ProseMirrorとは
ProseMirrorはリッチテキストエディターを実装するライブラリの1つです。
ドキュメントを最終的に得られる見た目のまま編集するWYSIWYGや、メンションや画像を使える少し工夫したエディタを開発することができます。
NotionやSlackの入力欄みたいなイメージです。
どこを見ればいい?
prosemirror-view
がDOM周りを担当していそうだったので、そこを見ていきます。
今回はキー入力なので、keydown
やinput
のイベントハンドラを中心的に見ていけば良さそうです。
以下の箇所を中心に見ていきます。
- キー入力は、ブラウザで制御 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を入力すると、初期状態ではkeydown
のcatureKeyDown
が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