リスト項目のバックスペースがアタッチメントを選択してしまう問題を修正
アタッチメント直後の空リスト項目でバックスペースを押すと、アタッチメントが選択されてしまう問題が修正されました。selectDecoratorNodeBeforeDeletion ハンドラにリスト項目を段落へ変換するロジックを追加することで、正しい編集体験を実現しています。
背景
Lexxyのエディタでは、アタッチメント(画像等)の直後に空のリスト項目が存在するとき、バックスペースキーを押しても期待通りに動作しませんでした。リスト項目が削除されるのではなく、直前のアタッチメントが選択状態になってしまうという問題が発生していました。
根本原因は selectDecoratorNodeBeforeDeletion ハンドラの動作にあります。このハンドラはバックスペース押下時にカーソル直前のデコレータノード(アタッチメント)を探して選択する役割を持っていますが、カーソルがリスト項目の先頭にある場合でも同じ経路で処理されていました。本来この状況では、Lexicalの collapseAtStart が呼ばれてリスト項目が段落に変換されるべきですが、selectDecoratorNodeBeforeDeletion が先に前のアタッチメントを選択してしまうため、collapseAtStart が実行されませんでした。
技術的な変更
src/editor/selection.js の selectDecoratorNodeBeforeDeletion メソッドに、リスト項目を検出して段落へ変換する早期リターン処理が追加されました。
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() でアンカーノードを取得し、$getNearestNodeOfType で ListItemNode と ListNode の祖先を探します。どちらも見つからなければ 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が不整合な状態にならないよう保護されています。
まとめ
この修正は、デコレータノード選択ハンドラがリスト項目のコンテキストを考慮していなかった設計上の盲点を解消するものです。既存の処理パイプラインへの変更を最小限にしつつ、リスト→段落変換のロジックを一箇所に集約することで、今後同様の「リスト直前にデコレータノードがある」ケースへの対応も同じメソッドで吸収できる構造になっています。