メンション挿入時に先行書式が継承されるバグを修正

basecamp/lexxy

メンションを入力したときに、行頭の書式(イタリックや太字)がそれ以降のプレーンテキストまで誤って適用される不具合を解消しました。これにより、テキストの部分的な装飾が期待通りに保持されます。

背景

テキストが書式付きの単語で始まり、続く部分が無装飾の場合、@ トリガーでメンションを挿入すると、最初の単語の書式が全体に拡散してしまうバグが報告されました。具体例として、*Italics* not applied to words other than the first にメンションを加えると、全体がイタリックになる現象が確認されています。原因は Contents#replaceTextBackUntil#performTextReplacement#cloneTextNodeFormatting の流れで、テキストノードの書式取得ロジックが選択中のタイピング書式を優先し、フォーマットが 0(プレーン)になると段落の書式にフォールバックしていたためです。

このロジックの欠陥により、プレーンテキストでも段落レベルのイタリックが継承され、期待しない装飾が付与されていました。結果としてユーザーはメンション挿入後にテキスト全体の見た目が変わるという不具合に直面していました。修正により、既存の書式が正確に保持され、エディタの WYSIWYG 挙動が改善されます。

技術的な変更

#cloneTextNodeFormatting が選択状態に依存せず、ノード自身の getFormat()getStyle() を直接参照するように変更されました。これに伴い、呼び出し側から不要となった selection 引数が削除され、メソッドシグネチャが簡素化されています。

@@ -244,14 +244,13 @@ export default class Contents {
   replaceTextBackUntil(stringToReplace, replacementNodes) {
     replacementNodes = Array.isArray(replacementNodes) ? replacementNodes : [ replacementNodes ]
-
-    const selection = $getSelection()
     const { anchorNode, offset } = this.#getTextAnchorData()
     if (!anchorNode) return
@@
-    this.#performTextReplacement(anchorNode, selection, lastIndex, stringToReplace, replacementNodes)
+    this.#performTextReplacement(anchorNode, lastIndex, stringToReplace, replacementNodes)
   }
@@
-  #performTextReplacement(anchorNode, selection, startIndex, stringToReplace, replacementNodes) {
+  #performTextReplacement(anchorNode, startIndex, stringToReplace, replacementNodes) {
@@
-    const textNodeBefore = this.#cloneTextNodeFormatting(anchorNode, selection, textBeforeString)
-    const textNodeAfter = this.#cloneTextNodeFormatting(anchorNode, selection, textAfterString || " ")
+    const textNodeBefore = this.#cloneTextNodeFormatting(anchorNode, textBeforeString)
+    const textNodeAfter = this.#cloneTextNodeFormatting(anchorNode, textAfterString || " ")
@@
-  #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)
-
+  #cloneTextNodeFormatting(anchorNode, text) {
@@
-    return $createTextNode(text)
-      .setFormat(format)
+    return $createTextNode(text)
+      .setFormat(anchorNode.getFormat())
       .setDetail(anchorNode.getDetail())
       .setMode(anchorNode.getMode())

#cloneTextNodeFormatting が取得する書式は anchorNode.getFormat() のみとなり、フォールバックロジックが除去されています。その結果、プレーンテキストは段落書式に汚染されず、元ノードと同一の書式で再生成されます。加えて、テスト mention_preserves_earlier_formatting.test.js が追加され、イタリックが最初の語だけに残ることを自動的に検証しています。

import { test } from "../../test_helper.js"
import { expect } from "@playwright/test"

test.describe("Mentions preserve earlier formatting", () => {
  test.beforeEach(async ({ page }) => {
    await page.goto("/mentions.html")
    await page.waitForSelector("lexxy-editor[connected]")
  })

  test("inserting a mention does not apply the first word's format to plain text before it", async ({ page, editor }) => {
    await editor.setValue("<p><em>Italics</em> not applied to words other than the first</p>")
    await editor.focus()
    await editor.send("End")
    await editor.send(" ")
    await editor.send("@")
    const popover = page.locator(".lexxy-prompt-menu--visible")
    await expect(popover).toBeVisible({ timeout: 5_000 })
    await editor.send("Enter")
    await editor.flush()
    await expect(editor.content.locator("action-text-attachment")).toBeVisible({ timeout: 5_000 })
    const italicTexts = await editor.content.evaluate((el) => {
      return Array.from(el.querySelectorAll("em, i")).map((node) => node.textContent.trim())
    })
    expect(italicTexts).toEqual([ "Italics" ])
  })
})

設計判断

書式コピー処理を 選択状態に依存しないシンプルな実装 に変更した点が本修正の核です。元の実装は selection.format が存在しない場合に段落書式へフォールバックしていたため、0(プレーン)が falsy と扱われ不適切な書式が付与されていました。今回の変更でフォーマット取得を anchorNode のみとし、余計なフォールバックや条件分岐を排除したことで、ロジックが明確化されました。

この決定は 後方互換性を保ちつつバグを根本的に排除 するという設計方針に沿っています。selection パラメータの削除に伴い、呼び出し側のシグネチャが簡素化され、将来的なメンテナンスコストが低減します。一方で、特定のケースで選択中の書式を意図的に利用したい拡張は別途実装が必要になる点がトレードオフです。

総じて、不要な依存を排除し データソースの単一化 を図った設計判断と評価でき、エディタの安定性向上に直結しています。

まとめ

本PRはメンション挿入時に先行書式が誤って拡散するバグを、テキストノードの書式コピーロジックをシンプル化することで修正しました。選択状態に依存しない実装に切り替えたことで、既存機能への影響を最小限に抑えつつ正しい書式保持が保証され、テストでの検証も追加されています。

記事メタデータ

Generated by:
gpt-oss-120b for DiffDaily
LLM Trace:
c86f8134

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

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
gpt-oss-120b for DiffDaily

Review Criteria:

記事構成 ✓ PASS

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

リード文 → 背景 → 技術的な変更 → 設計判断 → まとめ の流れが明確で、各要素が揃っている。まとめはリード文の繰り返しではなく、変更の意義を再提示している。

カスタムMarkdown構文 ✓ PASS

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

コードブロックは正しいシンタックスハイライト形式 (```diff, ```javascript:filepath) を使用。PRリンクは正しくマークダウンリンク化されている。

対象読者への適合性 ✓ PASS

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

対象はエンジニア向けで、専門用語や実装詳細が中心。初心者向けの過度な説明は無い。

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

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

各セクションは総論→各論→結論の構成で、段落はトピックセンテンスで始まり1トピックに絞られている。段落は6文以内で、空行で区切られている。

Diff内容との照合 ✓ PASS

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

記事内のdiffは提供されたPR diffと完全に一致。コード変更箇所、削除・追加行ともに正確に反映されている。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

使用されている技術用語はPRで使われているものと一致し、誤用は見られない。

説明の技術的正確性 ✓ PASS

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

技術的説明はPRの根本原因・修正内容と整合性が取れており、因果関係も正しい。

事実の突合 ✓ PASS

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

全ての主張はPRタイトル、説明、Diff、テストコードで裏付けられている。推測や外部情報は含まれていない。

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

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

PR番号 #1137 が正しく記載されている。その他数値的な記述はなし。

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

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

記事タイトルはPRの内容(メンション挿入時の書式バグ修正)を正確に表現している。

外部知識の正確性 ✓ PASS

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

PRに記載されていない外部知識(バージョンサポート等)は一切含まれていない。

時間表現の正確性 ✓ PASS

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

時間表現の歪曲はなく、PRの記述と一致している。