メンション挿入時にインラインフォーマットが失われるバグを修正

basecamp/lexxy

プロンプトメニューからメンションを挿入する際、周囲のボールドなどのインライン書式が失われていたバグを修正しました。テキストノード再構築時に元の書式情報を引き継ぐ #cloneTextNodeFormatting メソッドを導入しています。

背景

プロンプト挿入(prompt insertion) は、ユーザーが入力したトリガーテキストを replaceTextBackUntil で除去し、メンションのアタッチメントノードに置き換える処理です。この処理では、カーソル前後のテキストを一度分割して新しいテキストノードとして再構築しますが、その際に元テキストノードが持っていた書式情報が引き継がれていませんでした。結果として、ボールド体の途中にメンションを挿入すると、挿入前後のテキストが通常体に戻ってしまう問題が発生していました。

技術的な変更

#performTextReplacement の呼び出し前に $getSelection() で現在の選択状態を取得し、新たに追加された #cloneTextNodeFormatting メソッドへ渡すことで書式を引き継ぐようになりました。

変更前:

replaceTextBackUntil(stringToReplace, replacementNodes) {
  replacementNodes = Array.isArray(replacementNodes) ? replacementNodes : [ replacementNodes ]

  this.editor.update(() => {
    const { anchorNode, offset } = this.#getTextAnchorData()
    if (!anchorNode) return

    const lastIndex = this.#findLastIndexBeforeCursor(anchorNode, offset, stringToReplace)
    if (lastIndex === -1) return

    this.#performTextReplacement(anchorNode, offset, lastIndex, replacementNodes)
  })
}

変更後:

replaceTextBackUntil(stringToReplace, replacementNodes) {
  replacementNodes = Array.isArray(replacementNodes) ? replacementNodes : [ replacementNodes ]

  const selection = $getSelection()
  const { anchorNode, offset } = this.#getTextAnchorData()
  if (!anchorNode) return

  const lastIndex = this.#findLastIndexBeforeCursor(anchorNode, offset, stringToReplace)
  if (lastIndex === -1) return

  this.#performTextReplacement(anchorNode, selection, offset, lastIndex, replacementNodes)
}

テキストノードの再生成箇所では、$createTextNode の素の呼び出しから #cloneTextNodeFormatting 経由の生成に切り替わっています。

変更前:

const textNodeBefore = $createTextNode(textBeforeString)
const textNodeAfter = $createTextNode(textAfterCursor || " ")

変更後:

const textNodeBefore = this.#cloneTextNodeFormatting(anchorNode, selection, textBeforeString)
const textNodeAfter = this.#cloneTextNodeFormatting(anchorNode, selection, textAfterCursor || " ")

新設された #cloneTextNodeFormatting の実装は次のとおりです。

#cloneTextNodeFormatting(anchorNode, selection, text) {
  const parent = anchorNode.getParent()
  const fallbackFormat = parent?.getTextFormat?.() || 0
  const fallbackStyle = parent?.getTextStyle?.() || ""
  const format = $isRangeSelection(selection) && selection.format
    ? selection.format
    : (anchorNode.getFormat() || fallbackFormat)
  const style = $isRangeSelection(selection) && selection.style
    ? selection.style
    : (anchorNode.getStyle() || fallbackStyle)
  // ...
}

書式の取得優先順位は「選択状態(RangeSelection)のformat/style → anchorNode自身のformat/style → 親ノードのフォールバック」の3段階です。これにより、選択範囲を伴う挿入でも単純なカーソル位置での挿入でも、いずれのケースで書式が正しく引き継がれます。

また、replaceTextBackUntil から this.editor.update() のラッパーが取り除かれています。これは $getSelection() が Lexical の update コンテキスト外では機能しないためで、呼び出し元がすでに update コンテキスト内で実行されることを前提とした変更です。

設計判断

書式の取得元を複数フォールバックで束ねる設計 が採用されました。

Lexical のテキストノードは format(ビットフラグによるbold/italic等)と style(インラインCSSスタイル)の2つの書式情報を持ちます。RangeSelection が有効な場合はそちらを優先することで、ユーザーが書式を適用した状態でトリガー入力した場合にも対応しています。anchorNode.getFormat() が0(未設定)のときは親ノードから取得するフォールバックにより、ネストされたマークアップ構造でも書式が失われません。

新規メソッドを #performTextReplacement に分離したことで、テキストノード生成ロジックから書式継承ロジックが独立し、将来のスタイル取得方法の変更が局所化されています。

まとめ

本修正は、テキストノードの再構築時に書式情報を明示的に引き継ぐ #cloneTextNodeFormatting を導入することで、プロンプト挿入のあらゆる呼び出し経路における書式消失を根本的に解決しています。クリック選択とキーボード選択の両方を網羅するシステムテストも追加されており、同種のリグレッションを防ぐ安全網が整備されました。

記事メタデータ

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

この記事は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:src/editor/contents.js`)およびGitHubのPRリンク記法(`[PR #827](URL)`)が正しく使用されています。

対象読者への適合性 ✓ PASS

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

Lexicalフレームワークの「テキストノード」「RangeSelection」「format」といった専門用語を前提として解説しており、対象読者である専門知識を持つエンジニアに適した内容です。

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

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

各セクションが総論→各論の構成になっており、各段落もトピックセンテンスで始まるなど、パラグラフ・ライティングの原則が守られていて非常に読みやすいです。

Diff内容との照合 ✓ PASS

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

記事内で引用されているコードブロック(`replaceTextBackUntil`の変更、`#cloneTextNodeFormatting`の導入など)は、提供されたDiff情報(`src/editor/contents.js`)と完全に一致しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「プロンプト挿入」「インラインフォーマット」「テキストノード」など、PR情報やLexicalフレームワークで使われる技術用語が正確に使用されています。

説明の技術的正確性 ✓ PASS

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

書式取得の優先順位(選択状態→anchorNode→親ノード)や、`editor.update`が削除された理由など、Diffのコード変更に対する技術的な説明が正確かつ論理的です。

事実の突合 ✓ PASS

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

記事内の主張はすべてPRのTitle, Description, Diff内容で裏付けられています。特にシステムテストの追加に関する言及は、Diff内のテストコードファイル(`test/system/prompts_test.rb`)の変更と一致しており、ハルシネーションは見られません。

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

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

PR番号(#827)、メソッド名(`#cloneTextNodeFormatting`)、ファイルパス(`src/editor/contents.js`)などの数値・固有名詞はすべて正確です。

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

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

記事のタイトル「メンション挿入時にインラインフォーマットが失われるバグを修正」は、PRのタイトル「Preserve formatting around inserted mentions」の内容を的確に要約しています。

外部知識の正確性 ✓ PASS

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

記事の内容はすべて提供されたPR情報に基づいており、バージョンサポートやリリース日程といったPRに記載のない外部知識の捏造はありません。

時間表現の正確性 ✓ PASS

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

PRが完了した修正であることを踏まえ、記事全体が「修正しました」「導入しています」といった適切な時制で記述されており、時間表現の歪曲はありません。