リスト項目のバックスペースがアタッチメントを選択してしまう問題を修正

basecamp/lexxy

アタッチメント直後の空リスト項目でバックスペースを押すと、アタッチメントが選択されてしまう問題が修正されました。selectDecoratorNodeBeforeDeletion ハンドラにリスト項目を段落へ変換するロジックを追加することで、正しい編集体験を実現しています。

背景

Lexxyのエディタでは、アタッチメント(画像等)の直後に空のリスト項目が存在するとき、バックスペースキーを押しても期待通りに動作しませんでした。リスト項目が削除されるのではなく、直前のアタッチメントが選択状態になってしまうという問題が発生していました。

根本原因は selectDecoratorNodeBeforeDeletion ハンドラの動作にあります。このハンドラはバックスペース押下時にカーソル直前のデコレータノード(アタッチメント)を探して選択する役割を持っていますが、カーソルがリスト項目の先頭にある場合でも同じ経路で処理されていました。本来この状況では、Lexicalの collapseAtStart が呼ばれてリスト項目が段落に変換されるべきですが、selectDecoratorNodeBeforeDeletion が先に前のアタッチメントを選択してしまうため、collapseAtStart が実行されませんでした。

技術的な変更

src/editor/selection.jsselectDecoratorNodeBeforeDeletion メソッドに、リスト項目を検出して段落へ変換する早期リターン処理が追加されました。

selectDecoratorNodeBeforeDeletion 内にデコレータノードの選択処理より前に #collapseListItemToParagraph() の呼び出しが挿入されています。

変更前:

selectDecoratorNodeBeforeDeletion(backwards) {
  const node = backwards ? this.nodeBeforeCursor : this.nodeAfterCursor
  if (!$isDecoratorNode(node)) return false

  this.#removeEmptyElementAnchorNode()

  const selection = this.#selectInLexical(node)
  return Boolean(selection)
}

変更後:

selectDecoratorNodeBeforeDeletion(backwards) {
  const node = backwards ? this.nodeBeforeCursor : this.nodeAfterCursor
  if (!$isDecoratorNode(node)) return false

  if (this.#collapseListItemToParagraph()) return true

  this.#removeEmptyElementAnchorNode()

  const selection = this.#selectInLexical(node)
  return Boolean(selection)
}

新たに追加された #collapseListItemToParagraph() メソッドは次の処理を行います。まず $getSelection()?.anchor?.getNode() でアンカーノードを取得し、$getNearestNodeOfTypeListItemNodeListNode の祖先を探します。どちらも見つからなければ false を返して通常のデコレータ選択処理に委譲します。

#collapseListItemToParagraph() {
  const anchorNode = $getSelection()?.anchor?.getNode()
  const listItem = anchorNode && $getNearestNodeOfType(anchorNode, ListItemNode)
  if (!listItem) return false

  const listNode = $getNearestNodeOfType(listItem, ListNode)
  if (!listNode) return false

  const paragraph = $createParagraphNode()
  const children = listItem.getChildren()
  children.forEach(child => paragraph.append(child))

  if (listNode.getChildrenSize() === 1) {
    listNode.insertBefore(paragraph)
    listNode.remove()
  } else {
    listNode.insertBefore(paragraph)
    listItem.remove()
  }

  paragraph.selectStart()
  return true
}

リスト項目が確認できた場合は、$createParagraphNode() で新しい段落ノードを生成し、リスト項目の子ノードをすべて段落に移動します。その後、リストに他の項目が残っているかどうかで分岐します。リスト項目が1つだけなら listNode ごと削除し、複数の項目があれば当該 listItem のみ削除します。最後に paragraph.selectStart() でカーソルを段落の先頭に設定して true を返すことで、後続のデコレータ選択処理をスキップします。

テストは test/browser/tests/attachments/list_after_attachment.test.js として追加されており、Playwrightを使って次の2ケースを検証しています。

  • 空のリスト項目でバックスペースを押すとリストが削除され、アタッチメントが選択されないこと
  • テキストを削除して空になったリスト項目でバックスペースを押しても同様に動作すること

設計判断

デコレータノード選択ハンドラの内部にリスト項目の変換ロジックを組み込む方針が採用されました。

collapseAtStart をそのまま活用する」という方針も考えられますが、この修正では selectDecoratorNodeBeforeDeletion 内部でリスト→段落変換を完結させています。これはLexicalの collapseAtStart が実行される前段階の処理として selectDecoratorNodeBeforeDeletion が割り込む構造になっているため、その割り込みを「デコレータ選択が不要なケース」として早期リターンで排除する形が最も局所的な変更となります。

listNode.getChildrenSize() === 1 の分岐によってリスト全体の削除と単一項目の削除を明示的に分けている点も注目できます。単純に listItem.remove() だけを呼んだ場合、空のリストノードが残留する可能性があるため、この条件分岐によってエディタのDOMが不整合な状態にならないよう保護されています。

まとめ

この修正は、デコレータノード選択ハンドラがリスト項目のコンテキストを考慮していなかった設計上の盲点を解消するものです。既存の処理パイプラインへの変更を最小限にしつつ、リスト→段落変換のロジックを一箇所に集約することで、今後同様の「リスト直前にデコレータノードがある」ケースへの対応も同じメソッドで吸収できる構造になっています。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
13fea321

この記事は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/selection.js)およびPR番号のリンク記法([PR #840](URL))が正しく使用されています。

対象読者への適合性 ✓ PASS

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

LexicalのAPIやエディタの内部動作に関する専門用語が適切に使用されており、対象読者であるエンジニアに適した技術レベルで記述されています。

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

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

各セクションが総論→各論の構成で書かれており、各段落もトピックセンテンスで始まるなど、パラグラフ・ライティングの原則が守られています。これにより高い可読性が確保されています。

Diff内容との照合 ✓ PASS

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

記事で引用されているコードブロックは、`src/editor/selection.js`のDiff内容と完全に一致しており、変更点と追加されたメソッドが正確に反映されています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

`selectDecoratorNodeBeforeDeletion`や`ListItemNode`など、Lexicalフレームワークに関連する技術用語が正確かつ文脈に即して使用されています。

説明の技術的正確性 ✓ PASS

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

問題の根本原因や修正内容の技術的な説明は、PRのDescriptionやDiffの内容と一致しており、論理的で正確です。

事実の突合 ⚠ WARNING

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

ほとんどの主張はPR情報で裏付けられていますが、「設計判断」セクションで言及されている代替案(`collapseAtStart` を活用する方針)はPRに明記されておらず、コードからの推測に基づいています。

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

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

PR番号である「#840」が正確に記載・リンクされています。

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

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

記事のタイトルは、PRのタイトルとDescriptionの内容を的確に要約しており、主題と一致しています。

外部知識の正確性 ✓ PASS

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

記事の内容は提供されたPR情報に限定されており、バージョン情報やリリース予定など、PR外の外部知識の追加はありません。

時間表現の正確性 ✓ PASS

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

完了した修正について「〜が修正されました」といった過去形を使用しており、時間表現はPRの状況と正確に一致しています。