画像アップロード後のキャレット位置ずれを修正

basecamp/lexxy

空のエディタに画像をアップロードした後、キャレットが添付ファイルの下に表示されているにもかかわらず、実際の入力位置は添付ファイルの上になってしまうという不整合を修正しました。選択状態の管理方法を変更し、アップロード完了後にカーソルが自然に添付ファイルの下へ移動するようになります。

背景

空のエディタに画像をアップロードすると、視覚的なキャレット位置と実際のテキスト入力位置がずれるという問題がありました。ユーザーはキャレットが画像の下にあると認識してテキストを入力しますが、実際には画像の上(エディタ内の先頭位置)にテキストが挿入されてしまっていました。

この問題の根本原因は、アップロード完了時の選択状態の引き継ぎ方にありました。旧実装では $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 では、ProvisionalParagraphNodeisSelected() メソッドを拡張しました。この仮段落ノードは画像の直後に置かれる不可視のスペーサーですが、カーソルがその位置にあるときに正しく「選択中」と判定できていないケースがありました。

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 の選択判定拡張を組み合わせることで、画像アップロード後のキャレット不整合を解消しました。ノード選択の「対称的な引き継ぎ」よりもユーザーの自然な操作フローを優先するという判断が、この修正の設計思想の中心にあります。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
9da8c850

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

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

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

「総論→各論→結論」の構成が明確で、リード文、背景、技術詳細、設計判断、まとめの全要素が適切に配置されています。

カスタムMarkdown構文 ✓ PASS

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

ファイル名付きシンタックスハイライトの形式(```言語:ファイルパス)や、PR番号のリンク記法([#892](URL))が正しく使用されています。

対象読者への適合性 ✓ PASS

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

Lexicalフレームワークの内部実装に関する専門的な内容であり、対象読者であるエンジニアに適した技術レベルと表現です。

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

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

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

Diff内容との照合 ✓ PASS

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

Diff内のコード変更(変更前・変更後)を正確に引用しており、テストコードの追加についても言及があるなど、Diff内容との照合性は高いです。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

`selectNext()`, `$isRangeSelection`, `ProvisionalParagraphNode`など、Lexicalフレームワークの技術用語を文脈に沿って正確に使用しています。

説明の技術的正確性 ✓ PASS

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

コード変更の意図や影響について、技術的に正確かつ論理的に説明されています。特に`isSelected`メソッドの拡張ロジックに関する解説は明快です。

事実の突合 ✓ PASS

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

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

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

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

PR番号(#892)やファイルパス、関数名などの固有名詞はすべて正確です。

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

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

記事のタイトル「画像アップロード後のキャレット位置ずれを修正」と本文の要旨は、PRの主題「Fix caret rendering below attachment after upload」を的確に反映しています。

外部知識の正確性 ✓ PASS

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

PRで言及されていないバージョン情報やサポート状況などの外部知識は含まれていません。

時間表現の正確性 ✓ PASS

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

過去に存在した問題と今回の変更による修正という時間軸が正確に表現されており、PRの内容と矛盾はありません。