@メンションのカーソル挙動を修正:ゼロ幅文字で「アトミックなノード」として扱う
CustomActionTextAttachmentNode の getTextContent() がメンション名のフルテキストを返していたため、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.js の getTextContent() が、メンション名の代わりにゼロ幅文字(\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.js の toString() メソッドが更新され、$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() への影響を最小化するという設計は、フレームワークの内部規約を尊重しながら問題を解消するアプローチの好例といえる。