@メンションのカーソル挙動を修正:ゼロ幅文字で「アトミックなノード」として扱う

basecamp/lexxy

CustomActionTextAttachmentNodegetTextContent() がメンション名のフルテキストを返していたため、Lexical がメンションを複数のカーソル位置として扱い、Shift+矢印キーによる選択や削除が正常に機能しなかった。本PRでは、getTextContent() をゼロ幅文字(U+FEFF)返すように変更し、メンションを1つのアトミックなカーソル位置として扱うようにした。

背景

Lexical はノードのテキスト長を getTextContent() の戻り値から計算し、カーソル位置を管理する。CustomActionTextAttachmentNode(@メンションに使用)が「Alice」のような可視テキストを返していたため、Lexical はこのノードを5文字分のカーソル位置として管理していた。

この結果、2つの問題が発生していた。1つ目は、Shift+矢印キーによる選択でメンション名を1文字ずつステップスルーしてしまう問題。2つ目は、削除時にカーソルが誤った位置に残るか、メンション全体が削除されない問題。本来インライン装飾ノードは1つのアトミックな単位として扱われるべきである。

技術的な変更

変更は3つのコンポーネントにまたがっており、「ゼロ幅文字による原子化」「Shift キー時のハンドラーバイパス」「可読テキストの保全」という3つの対策が組み合わされている。

getTextContent() のゼロ幅文字化

src/nodes/custom_action_text_attachment_node.jsgetTextContent() が、メンション名の代わりにゼロ幅文字(\ufeff、BOM文字)を返すよう変更された。

変更前:

getTextContent() {
  return this.createDOM().textContent.trim() || `[${this.contentType}]`
}

変更後:

getTextContent() {
  return "\ufeff"
}

getReadableTextContent() {
  return this.createDOM().textContent.trim() || `[${this.contentType}]`
}

Lexical がカーソル位置を計算する際、このノードは常に1文字分として計上される。一方、以前の getTextContent() の実装は getReadableTextContent() として分離され、メンション名の可読テキストは維持される。

Shift キー時の矢印キーハンドラーバイパス

src/editor/selection.js#selectPreviousNode()#selectNextNode()event 引数が追加され、shiftKey が押されているときは早期リターンするよう変更された。

変更前:

async #selectPreviousNode() {
  if (this.hasNodeSelection) {
    return await this.#withCurrentNode((currentNode) => currentNode.selectPrevious())
  } else {
    return this.#selectInLexical(this.nodeBeforeCursor)
  }
}

async #selectNextNode() {
  if (this.hasNodeSelection) {
    return await this.#withCurrentNode((currentNode) => currentNode.selectNext(0, 0))
  } else {
    return this.#selectInLexical(this.nodeBeforeCursor)
  }
}

変更後:

async #selectPreviousNode(event) {
  if (event?.shiftKey) return false

  if (this.hasNodeSelection) {
    return await this.#withCurrentNode((currentNode) => currentNode.selectPrevious())
  } else {
    return this.#selectInLexical(this.nodeBeforeCursor)
  }
}

async #selectNextNode(event) {
  if (event?.shiftKey) return false

  if (this.hasNodeSelection) {
    return await this.#withCurrentNode((currentNode) => currentNode.selectNext(0, 0))
  } else {
    return this.#selectInLexical(this.nodeBeforeCursor)
  }
}

false を返すことでカスタムハンドラーの処理をスキップし、Shift+矢印キーによる範囲選択をブラウザネイティブの動作に委ねる。これにより、ノード選択への変換が行われず、自然な範囲選択が維持される。

toString() における可読テキストの保全

src/elements/editor.jstoString() メソッドが更新され、$getRoot().getTextContent() の代わりに新しい $getReadableTextContent() 関数を使用するようになった。

function $getReadableTextContent(node) {
  if (node instanceof CustomActionTextAttachmentNode) {
    return node.getReadableTextContent()
  }

  if ($isElementNode(node)) {
    let text = ""
    const children = node.getChildren()
    for (let i = 0; i < children.length; i++) {
      const child = children[i]
      text += $getReadableTextContent(child)
      if ($isElementNode(child) && i !== children.length - 1 && !child.isInline()) {
        text += "\n\n"
      }
    }
    return text
  }

  return node.getTextContent()
}

この関数はノードツリーを再帰的に走査し、CustomActionTextAttachmentNode に遭遇した場合のみ getReadableTextContent() を呼び出す。その他のノードは従来通り getTextContent() にフォールバックするため、既存の動作に影響を与えない。また、$isElementNode のインポートが lexical から追加されている。

ブラウザテストの追加

test/browser/tests/mention_deletion.test.js が新規追加され、Playwright を用いてメンション削除時のカーソル挙動が検証される。テストでは「Hello [Alice] world」というコンテンツに対して、カーソルをメンション直前に配置後、Shift+ArrowRight で選択してから Backspace で削除するシナリオが記述されている。

設計判断

getTextContent() の責務を「カーソル管理用」と「可読テキスト提供用」に分離するアプローチが採用された。

getTextContent() を上書きしてゼロ幅文字を返すことで、Lexical の内部カーソル管理を変更せずにノードの「アトミック性」を実現している。Lexical のテキスト長ベースのカーソル計算という根本的な仕組みに合わせることで、最小限の変更で問題を解決している。また、$getReadableTextContent()$ プレフィックスで命名することで、Lexical のエディタ状態 read()/update() コンテキスト内でのみ呼び出すべき関数であるというLexical の規約に従っている。

Shift キーの処理をブラウザネイティブに委譲する判断も同様の方針を示している。カスタムハンドラーが範囲選択の拡張(Shift+矢印)まで処理しようとするのではなく、その処理はブラウザに任せることで、デコレーターノード周辺の複雑な選択ステートマシンの実装を回避している。

まとめ

本PRは getTextContent() をゼロ幅文字に変更するという小さな変更で、カーソル管理・範囲選択・削除という複数の動作を一貫して修正した。Lexical のカーソル位置計算の仕組みに合わせる形でインライン装飾ノードのアトミック性を確保しつつ、可読テキストを別メソッドに分離することで toString() への影響を最小化するという設計は、フレームワークの内部規約を尊重しながら問題を解消するアプローチの好例といえる。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
7fc41d5b

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

品質レビュー結果

Review Status:
リトライ後承認
Review Count:
2回 (改善を経て承認)
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

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

「リード文(総論)→背景・技術詳細・設計判断(各論)→まとめ(結論)」という構成が明確であり、ガイドラインを完全に満たしています。

カスタムMarkdown構文 ✓ PASS

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

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

対象読者への適合性 ✓ PASS

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

Lexicalの内部実装やカーソル管理といった専門的なトピックを扱っており、対象読者であるエンジニアに適した技術レベルと表現です。

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

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

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

Diff内容との照合 ✓ PASS

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

記事内のコードスニペットは、提供されたDiff情報と完全に一致しています。ファイルパスの指定も正確です。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「ゼロ幅文字」「アトミックなノード」「デコレーターノード」など、関連する技術用語が正確かつ適切に使用されています。

説明の技術的正確性 ✓ PASS

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

「ゼロ幅文字を返すことでノードをアトミックに扱う」という核心的な説明が、DiffとPRの趣旨に完全に整合しており、技術的に正確です。

事実の突合 ✓ PASS

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

記事内のすべての主張は、PRのDescriptionやDiff内のコード変更によって裏付けられており、ハルシネーションは検出されませんでした。

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

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

PR番号(#850)や関数名、ゼロ幅文字(\ufeff)の表記など、すべての数値・固有名詞が正確です。

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

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

記事のタイトルはPRの主題「@メンションのカーソル挙動の修正」を正確に反映し、さらに「ゼロ幅文字でアトミックに扱う」という解決策の核心を要約しており、非常に優れています。

外部知識の正確性 ✓ PASS

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

記事の内容は提供されたPR情報に限定されており、バージョン情報やサポート状況といった外部知識の追記はありません。

時間表現の正確性 ✓ PASS

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

PRで実施された変更を客観的な事実として記述しており、時間表現の歪曲や不正確な記述は見られません。