画像アップロード後のキャレット位置ずれを修正
空のエディタに画像をアップロードした後、キャレットが添付ファイルの下に表示されているにもかかわらず、実際の入力位置は添付ファイルの上になってしまうという不整合を修正しました。選択状態の管理方法を変更し、アップロード完了後にカーソルが自然に添付ファイルの下へ移動するようになります。
背景
空のエディタに画像をアップロードすると、視覚的なキャレット位置と実際のテキスト入力位置がずれるという問題がありました。ユーザーはキャレットが画像の下にあると認識してテキストを入力しますが、実際には画像の上(エディタ内の先頭位置)にテキストが挿入されてしまっていました。
この問題の根本原因は、アップロード完了時の選択状態の引き継ぎ方にありました。旧実装では $createNodeSelectionWith を使ってアップロードノード(ActionTextAttachmentUploadNode)の選択状態を置換後のノード(ActionTextAttachmentNode)へコピーしていました。この方式では、置換後もノード自体が選択された状態になり、キャレットを末尾の段落に移動させることができませんでした。
技術的な変更
変更は ActionTextAttachmentUploadNode の選択管理と ProvisionalParagraphNode の選択判定という2つのファイルにまたがっています。
src/nodes/action_text_attachment_upload_node.js では、アップロード完了後の選択状態管理ロジックを刷新しました。
変更前:
const shouldTransferNodeSelection = editorHasFocus && this.isSelected()
const replacementNode = this.#toActionTextAttachmentNodeWith(blob)
this.replace(replacementNode)
if (shouldTransferNodeSelection) {
const nodeSelection = $createNodeSelectionWith(replacementNode)
$setSelection(nodeSelection)
}
変更後:
const replacementNode = this.#toActionTextAttachmentNodeWith(blob)
this.replace(replacementNode)
if (editorHasFocus && $isRootOrShadowRoot(replacementNode.getParent())) {
replacementNode.selectNext()
}
$createNodeSelectionWith と $setSelection を使ってノード選択を引き継ぐ代わりに、selectNext() を呼び出すことで次のノード(末尾の仮段落)へカーソルを移動させます。$isRootOrShadowRoot による親ノードの確認は、添付ノードがルートの直接の子である場合にのみカーソル移動を行うための安全弁です。
src/nodes/provisional_paragraph_node.js では、ProvisionalParagraphNode の isSelected() メソッドを拡張しました。この仮段落ノードは画像の直後に置かれる不可視のスペーサーですが、カーソルがその位置にあるときに正しく「選択中」と判定できていないケースがありました。
isSelected(selection = null) {
const targetSelection = selection || $getSelection()
if (!targetSelection) return false
if (targetSelection.getNodes().some(node => node.is(this) || this.isParentOf(node))) return true
// A collapsed range selection on the parent element at an offset adjacent to
// this node means the caret is visually at this paragraph's position. Treat it
// as selected so the paragraph is visible and the caret renders correctly.
//
// Both the offset matching our index (cursor just before us) and index + 1
// (cursor just after us) count, because the provisional paragraph is an
// invisible spacer: the browser resolves both offsets to the same visual spot.
if ($isRangeSelection(targetSelection) && targetSelection.isCollapsed()) {
const { anchor } = targetSelection
const parent = this.getParent()
if (parent && anchor.getNode().is(parent) && anchor.type === "element") {
const index = this.getIndexWithinParent()
return anchor.offset === index || anchor.offset === index + 1
}
}
return false
}
追加されたロジックは、$isRangeSelection かつ collapsed(キャレット状態)の選択に対して、アンカーノードが親要素を指しておりその offset が仮段落の前後いずれかのインデックスに一致する場合も「選択中」と見なします。コメントにある通り、仮段落は不可視のスペーサーであるため、ブラウザはインデックス前後の両オフセットを視覚的に同一位置として解決します。これにより、仮段落が hidden クラスを外れて表示状態を維持し、キャレットが正しくレンダリングされます。
テストは2ケース追加されました。1つ目は仮段落が provisional-paragraph クラスを持ち hidden クラスを持たないことを確認するもの、2つ目は実際に文字を入力して画像の下のテキストとして挿入されることを検証するものです。
設計判断
ノード選択からカーソル移動へのアプローチ変換が今回の核心的な判断です。旧来の $setSelection によるノード選択の維持は「アップロード中に選択していたものを置換後も選択し続ける」という対称性を持つ設計でしたが、ノード選択とキャレット位置の視覚的整合性を保つことが難しいという欠点がありました。
selectNext() への変更は、ユーザーの意図(画像を挿入した後はその下にテキストを入力したい)を優先した判断です。$isRootOrShadowRoot によるガードを加えることで、ルート直下に配置されていない添付ノード(キャプション内など)での意図しないカーソル移動を防いでいます。
ProvisionalParagraphNode.isSelected() の拡張は、Lexicalのセレクションモデルと仮段落の「不可視スペーサー」という性質の狭間を埋める対処です。element 型アンカーを特別扱いすることで、DOM上の曖昧な位置解決を吸収しています。
まとめ
本PRは、selectNext() による明示的なカーソル移動と ProvisionalParagraphNode の選択判定拡張を組み合わせることで、画像アップロード後のキャレット不整合を解消しました。ノード選択の「対称的な引き継ぎ」よりもユーザーの自然な操作フローを優先するという判断が、この修正の設計思想の中心にあります。