`FormatEscaper`クラスをExtension・Nodeアーキテクチャへ置き換え

basecamp/lexxy

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.jsLexxyExtensionを継承し、defineExtensionでLexicalの拡張として登録されます。CodeNodeListItemNodeをそれぞれEarlyEscapeCodeNodeEarlyEscapeListItemNodeで置き換えるノード定義と、コマンドハンドラの登録を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. エスケープロジックのノードへの委譲

EarlyEscapeCodeNodesrc/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)
}

EarlyEscapeListItemNodesrc/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の設計思想に沿ったアプローチで、ノード自身がその振る舞いを定義します。

旧実装はコマンドハンドラの中でノード種別を判定していましたが、新実装ではCodeNodeListItemNodeを専用サブクラスで置き換えることで、型ベースのディスパッチを実現しています。コマンドハンドラはBlockquoteからのエスケープという「文脈依存の判定」のみを担い、ノード固有のエスケープロジックはノード自身に委譲されています。

INSERT_PARAGRAPH_COMMANDへの切り替えは後方互換性を保ちつつ、RangeSelectionが保証されるという副次的な堅牢性向上をもたらしています。また、$splitNodeなどのLexical公式ユーティリティの採用により、手書きの分割ロジックが排除されています。

まとめ

この変更は単なるリファクタリングにとどまらず、388行のモノリシックなクラスを「Extension・ノードサブクラス・共通ヘルパー」の3層に分解することで、各コンポーネントの責務を明確化しています。Lexicalのノード置換機能とdefineExtensionを活用した構造は、今後の機能追加や個別のノード挙動変更を局所的な修正で済ませられる拡張性を持ちます。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
3f886b5d

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

リード文(総論)→背景・技術詳細・設計判断(各論)→まとめ(結論)という3部構成が明確に適用されています。必須要素はすべて含まれており、まとめもリード文の単なる繰り返しではなく、変更の意義を深掘りできています。

カスタムMarkdown構文 ✓ PASS

シンタックスハイライト・GitHubリンク記法の正確性

ファイル名付きシンタックスハイライト(```javascript:ファイルパス)およびGitHubのPRリンク記法([PR #897](URL))が正しく使用されています。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

LexicalのExtensionやNode、コマンドなど専門的なトピックを扱っており、対象読者であるエンジニアに適した技術レベルと表現で書かれています。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクションが総論→各論の構成で書かれ、各段落はトピックセンテンスで始まっています。1段落1トピックの原則が守られ、段落長も適切であるため、非常に高い可読性を確保しています。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事内で引用されているコードブロックは、提供されたDiffの内容と正確に一致しています。ファイルパスの指定も正確です。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「INSERT_PARAGRAPH_COMMAND」「$splitNode」「defineExtension」など、Lexicalに関連する技術用語やPR固有のクラス名が正確に使用されています。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

「INSERT_PARAGRAPH_COMMANDに切り替えることでRangeSelectionが保証される」という説明はPR Descriptionで裏付けられており、その他の技術的な説明もDiffの内容と整合性が取れています。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

記事内の主張はすべてPRのTitle、Description、Diffの内容で裏付けられています。「388行のモノリシックなクラス」という記述は、Diffで示された削除行数と一致しており、事実に基づいています。ハルシネーションは検出されませんでした。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR番号(#897)や削除されたコードの行数(388行)など、記事に含まれる数値や固有名詞はすべて正確です。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事のタイトル「`FormatEscaper`クラスをExtension・Nodeアーキテクチャへ置き換え」は、PRの主題を的確に表現しています。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

PR情報に基づかない外部知識(バージョンのサポート状況、リリース日程など)の追加はなく、すべての情報が提供された資料の範囲内に収まっています。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

「〜しました」「〜へ移行しました」といった過去形・完了形の表現が使われており、完了した変更であるというPRの文脈と時間表現が一致しています。