`FormatEscaper`クラスをExtension・Nodeアーキテクチャへ置き換え
FormatEscaperクラスを廃止し、FormatEscapeExtensionと専用ノード(EarlyEscapeCodeNode / EarlyEscapeListItemNode)に再設計することで、フォーマットエスケープ機能をLexicalのアーキテクチャに則った形で実装し直しました。
背景
旧来のFormatEscaperは、エディタ要素に直接バインドされたクラスとして手書きのファインダーやイテレータを抱えており、Lexicalの提供するユーティリティが活用されていませんでした。Contentsコンストラクタ内でnew FormatEscaper(editorElement).monitor()と呼び出す方式は、他のExtensionが採用しているプラグインパターンとも整合していませんでした。
また、コードブロックやリストアイテムのエスケープロジックが1つのクラスに混在しており、各フォーマットタイプの責務が分離されていない状態でした。今回のPRはこの構造を解消し、ノード単位・Extension単位でロジックを分離することを目的としています。
技術的な変更
アーキテクチャ全体が「コマンドハンドラ中心のクラス」から「ノードとExtensionの組み合わせ」へと移行しました。変更は大きく3つの層に分かれています。
1. FormatEscapeExtensionの追加
新しい src/extensions/format_escape_extension.js はLexxyExtensionを継承し、defineExtensionでLexicalの拡張として登録されます。CodeNodeとListItemNodeをそれぞれEarlyEscapeCodeNode・EarlyEscapeListItemNodeで置き換えるノード定義と、コマンドハンドラの登録をlexicalExtensionゲッター内にまとめています。
export class FormatEscapeExtension extends LexxyExtension {
get lexicalExtension() {
return defineExtension({
name: "lexxy/format-escape",
nodes: [
EarlyEscapeCodeNode,
{ replace: CodeNode, with: (node) => new EarlyEscapeCodeNode(node.getLanguage()), withKlass: EarlyEscapeCodeNode },
EarlyEscapeListItemNode,
{ replace: ListItemNode, with: () => new EarlyEscapeListItemNode(), withKlass: EarlyEscapeListItemNode },
],
register(editor) {
return mergeRegister(
editor.registerCommand(
INSERT_PARAGRAPH_COMMAND,
() => $escapeFromBlockquote(),
COMMAND_PRIORITY_HIGH
),
editor.registerCommand(
KEY_ARROW_DOWN_COMMAND,
(event) => $handleArrowDownInCodeBlock(event),
COMMAND_PRIORITY_NORMAL
)
)
}
})
}
}
旧実装ではKEY_ENTER_COMMANDを直接ハンドルしていましたが、新実装では INSERT_PARAGRAPH_COMMAND に変更されています。これにより、Enterキーだけでなく段落挿入を発火するあらゆる操作に対応し、かつ$getSelection()がRangeSelectionであることが保証されます。
2. エスケープロジックのノードへの委譲
EarlyEscapeCodeNode(src/nodes/early_escape_code_node.js)はCodeNodeを継承し、insertNewAfterをオーバーライドします。カーソルがコードブロックの最終行の空行にある場合、$trimTrailingBlankNodesで末尾の空行を除去してからコードブロックの後ろにParagraphNodeを挿入し、フォーカスを移動させます。
insertNewAfter(selection, restoreSelection) {
if (!selection.isCollapsed()) return super.insertNewAfter(selection, restoreSelection)
if (this.#isCursorOnEmptyLastLine(selection)) {
$trimTrailingBlankNodes(this)
const paragraph = $createParagraphNode()
this.insertAfter(paragraph)
return paragraph
}
return super.insertNewAfter(selection, restoreSelection)
}
EarlyEscapeListItemNode(src/nodes/early_escape_list_item_node.js)はListItemNodeを継承し、同じくinsertNewAfterをオーバーライドします。Blockquote内の空リストアイテムでエスケープ条件を満たす場合、後続に空でないリストアイテムが存在するときは$splitNodeでBlockquoteを分割し、そうでなければリストの後ろに段落を挿入してリストアイテムを削除します。
3. lexical_helper.jsへのユーティリティ関数の追加
src/helpers/lexical_helper.js に3つのヘルパー関数が追加されました:
-
$isCursorOnLastLine(selection): カーソルが要素の最終行にあるかを、LineBreakNodeの位置を基準に判定する -
$isBlankNode(node): テキストコンテンツと子ノードを再帰的にチェックし、ノードが空白のみかを判定する -
$trimTrailingBlankNodes(parent):$lastToFirstIterator(@lexical/utils)を用いて末尾から順に空白ノードを削除する
これらは旧FormatEscaper内に散在していたロジックを汎用ヘルパーとして切り出したものです。
設計判断
エスケープの挙動をノードのinsertNewAfterメソッドとして実装する方針が採用されました。これはLexicalの設計思想に沿ったアプローチで、ノード自身がその振る舞いを定義します。
旧実装はコマンドハンドラの中でノード種別を判定していましたが、新実装ではCodeNodeとListItemNodeを専用サブクラスで置き換えることで、型ベースのディスパッチを実現しています。コマンドハンドラはBlockquoteからのエスケープという「文脈依存の判定」のみを担い、ノード固有のエスケープロジックはノード自身に委譲されています。
INSERT_PARAGRAPH_COMMANDへの切り替えは後方互換性を保ちつつ、RangeSelectionが保証されるという副次的な堅牢性向上をもたらしています。また、$splitNodeなどのLexical公式ユーティリティの採用により、手書きの分割ロジックが排除されています。
まとめ
この変更は単なるリファクタリングにとどまらず、388行のモノリシックなクラスを「Extension・ノードサブクラス・共通ヘルパー」の3層に分解することで、各コンポーネントの責務を明確化しています。Lexicalのノード置換機能とdefineExtensionを活用した構造は、今後の機能追加や個別のノード挙動変更を局所的な修正で済ませられる拡張性を持ちます。