インラインコード適用時のフラグメント化バグを修正

basecamp/lexxy

混在するインラインフォーマット(太字・斜体など)を含むテキストにコードフォーマットを適用すると、フォーマットごとに <code> 要素が分割されてしまう問題を修正しました。コード適用前に選択範囲のインラインフォーマットを除去することで、常に単一の <code> 要素を生成します。

背景

Lexicalエディタでは、異なるフォーマットを持つテキストノードは別々のノードとして管理されます。このため、「通常テキスト」「太字」「斜体」が混在する選択範囲に FORMAT_TEXT_COMMAND でコードフォーマットを一括適用すると、Lexicalがノードを結合できず、フォーマットの数だけ <code> 要素が生成されていました。

たとえば Hello <strong>bold</strong> and <em>italic</em> world 全体を選択してコードボタンを押すと、期待される <code>Hello bold and italic world</code> ではなく、<code>Hello </code><code>bold</code><code> and </code><code>italic</code><code> world</code> のように断片化した出力が生成されていました。

この問題の本質は、Lexicalが FORMAT_TEXT_COMMAND を適用する際、既存フォーマットを持つノードをそのまま保持するため、フォーマットが異なるノード同士が結合されないという仕様にあります。

技術的な変更

dispatchInsertCodeBlock()FORMAT_TEXT_COMMAND を直接呼び出す代わりに、新たに追加した #toggleInlineCode() プライベートメソッドを呼び出すよう変更されました。このメソッドがコード適用前の前処理を担います。

変更前:

dispatchInsertCodeBlock() {
  if (this.selection.hasSelectedWordsInSingleLine) {
    this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "code")
  } else {
    this.contents.toggleCodeBlock()
  }
}

変更後:

dispatchInsertCodeBlock() {
  if (this.selection.hasSelectedWordsInSingleLine) {
    this.#toggleInlineCode()
  } else {
    this.contents.toggleCodeBlock()
  }
}

#toggleInlineCode() {
  const selection = $getSelection()
  if (!$isRangeSelection(selection)) return

  if (!selection.isCollapsed()) {
    const textNodes = selection.getNodes().filter($isTextNode)
    const applyingCode = !textNodes.every((node) => node.hasFormat("code"))

    if (applyingCode) {
      this.#stripInlineFormattingFromSelection(selection, textNodes)
    }
  }

  this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "code")
}

フォーマット除去の実装は #stripInlineFormattingFromSelection() が担います。このメソッドは、選択範囲が境界ノードの途中から始まる・終わるケースを正しく処理します。具体的には、選択範囲が境界ノードの途中にある場合に node.splitText(startOffset, endOffset) を呼び出してノードを分割し、選択範囲内の部分ノードのみに setFormat(0) を適用します。選択外のテキスト(例: <strong>bold text</strong> のうち選択されていない末尾の一部)は元のフォーマットを保持します。

#stripInlineFormattingFromSelection(selection, textNodes) {
  const isBackward = selection.isBackward()
  const startPoint = isBackward ? selection.focus : selection.anchor
  const endPoint = isBackward ? selection.anchor : selection.focus

  for (let i = 0; i < textNodes.length; i++) {
    const node = textNodes[i]
    if (node.getFormat() === 0) continue

    const isFirst = i === 0
    const isLast = i === textNodes.length - 1
    const startOffset = isFirst && startPoint.type === "text" ? startPoint.offset : 0
    const endOffset = isLast && endPoint.type === "text" ? endPoint.offset : node.getTextContentSize()

    if (startOffset === 0 && endOffset === node.getTextContentSize()) {
      node.setFormat(0)
    } else {
      const splits = node.splitText(startOffset, endOffset)
      const target = startOffset === 0 ? splits[0] : splits[1]
      target.setFormat(0)
    }
  }
}

また、#toggleInlineCode() 内では「コードを適用しようとしているか(applyingCode)」の判定も行っています。選択中のすべてのノードが既にコードフォーマットを持つ場合(つまりトグルオフ操作)は、フォーマット除去をスキップして直接 FORMAT_TEXT_COMMAND に委譲します。

設計判断

Lexicalの FORMAT_TEXT_COMMAND をそのまま維持し、その手前に前処理を挿入する構成 が採用されました。Lexicalの内部ノード管理を直接操作するのではなく、コマンドディスパッチ前にノードの状態を正規化するアプローチです。これにより、コードフォーマットの適用・解除といったコアロジックはLexicalに委ねたまま、フラグメント化の問題のみをアプリケーション層で解決しています。

部分選択への対応に splitText() を活用している点も注目されます。選択境界がノードの途中にある場合、ノードを分割してから選択範囲内のノードにのみ setFormat(0) を適用することで、選択外テキストのフォーマットが損なわれません。テストケースでは Hello <strong>bold text</strong> and <em>italic text</em> world から「bold text and italic」を部分選択してコードを適用し、Hello <code>bold text and italic</code><em> text</em> world が得られることを検証しています。

この設計は、Lexicalのノードモデルを深く変更することなく、最小限のコード追加でバグを修正しています。CommandDispatcher クラスに閉じた実装であり、影響範囲が限定的です。

まとめ

この修正は、Lexicalのノードモデルに起因するフォーマットフラグメント化の問題を、コマンド実行前の前処理として解決した変更です。splitText() を用いた境界処理によって部分選択のケースにも対応しており、選択外フォーマットの保持と選択内の単一 <code> 要素生成を両立しています。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
cc92ec58

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

品質レビュー結果

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

Review Criteria:

記事構成 ✓ PASS

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

「リード文(総論)→セクション群(各論)→まとめ(結論)」という構成が明確です。背景、技術的変更、設計判断の各セクションが論理的に配置されており、非常に分かりやすい記事構造になっています。

カスタムMarkdown構文 ✓ PASS

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

ファイル名付きシンタックスハイライト(```javascript:src/editor/command_dispatcher.js)およびPR番号のリンク記法([PR #886](URL))が正しく使用されています。

対象読者への適合性 ✓ PASS

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

Lexicalエディタの内部実装に関する内容であり、専門知識を持つエンジニアという対象読者に完全に適合しています。冗長な説明はなく、専門用語が的確に使用されています。

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

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

各セクション、各パラグラフがガイドラインに準拠しています。特に、各段落の先頭にトピックセンテンスが置かれているため、見出しと各段落の1文目だけで記事の骨子を掴むことができます。

Diff内容との照合 ✓ PASS

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

記事内のすべてのコードブロックは、提供されたDiffの内容と正確に一致しています。変更前・変更後のコード引用が的確で、技術的な変更点を正確に示しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「Lexical」「FORMAT_TEXT_COMMAND」「splitText」などの技術用語が、文脈に沿って正確に使用されています。

説明の技術的正確性 ✓ PASS

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

インラインフォーマットを除去してからコードフォーマットを適用するという変更の核心が、Diffの内容と一致しており、技術的に正確に説明されています。

事実の突合 ✓ PASS

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

記事内のすべての主張がPRのDescriptionやDiff内のコード、特にテストコードの内容によって裏付けられています。根拠のない推測や創作は見られません。

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

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

PR番号(#886)が正確に記載されています。その他の数値や固有名詞についても誤りはありません。

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

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

記事のタイトル「インラインコード適用時のフラグメント化バグを修正」は、PRのタイトル「Fix inline code fragmenting with mixed formatting」の内容を正確に反映しています。

外部知識の正確性 ✓ PASS

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

PR情報に記載のない、バージョンサポート状況やリリース日程などの外部知識は一切含まれておらず、提供された情報源に忠実です。

時間表現の正確性 ✓ PASS

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

「修正しました」など、完了した変更を表す時間表現が適切に使用されており、PRの状況と一致しています。