ギャラリーへのドロップ時に元のカーソル位置を保持する修正

basecamp/lexxy

OSからファイルをドラッグ&ドロップでイメージギャラリーに挿入する際、カーソルの右側へのドロップで画像が誤った位置に挿入されていたバグを修正しました。ドラッグ開始時の選択状態を保存・復元することで、ドロップ先に関わらず常に元のカーソル位置へ画像が挿入されるようになります。

背景

ドラッグ操作中に発生するブラウザの selectionchange イベントが、Lexicalの内部選択状態を意図せず移動させることが根本原因でした。ユーザーがカーソルをギャラリー内の特定位置(例:2枚の画像の間)に置いていても、OSからファイルをドラッグしてエディタ上に移動させると、ブラウザが生成するドラッグキャレットの動きに追従して selectionchange が発火し、Lexicalの選択状態が上書きされてしまいます。

その結果、drop イベントが発火した時点では選択状態がドラッグキャレットの最終位置を指しており、ファイルはユーザーが意図した位置ではなくドラッグキャレットが止まった位置に挿入されていました。この問題は特にカーソルの右側にドロップした場合に顕在化します。

技術的な変更

CommandDispatcher クラスに選択状態のスナップショット機構が追加されました。新たに導入されたプライベートフィールド #selectionBeforeDrag に、ドラッグ開始時点の選択状態を保持します。

src/editor/command_dispatcher.js への変更は以下の3点です:

  • #handleDragEnter: dragCounter が初めて1になったとき(エディタへの最初のドラッグ進入時)に #saveSelectionBeforeDrag() を呼び出す
  • #handleDragLeave: dragCounter が0に戻ったとき(ドロップなしでエディタ外へ離脱したとき)に #selectionBeforeDragnull にクリアする
  • #handleDrop: uploadFiles() の呼び出し前に #restoreSelectionBeforeDrag() を呼び出して選択状態を復元する
#saveSelectionBeforeDrag() {
  this.editor.getEditorState().read(() => {
    this.#selectionBeforeDrag = $getSelection()?.clone()
  })
}

#restoreSelectionBeforeDrag() {
  if (!this.#selectionBeforeDrag) return

  this.editor.update(() => {
    $setSelection(this.#selectionBeforeDrag)
  })

  this.#selectionBeforeDrag = null
}

選択状態の保存には editor.getEditorState().read() を、復元には editor.update() を使用しています。これはLexicalの読み取りと書き込みのAPIを正しく使い分けたパターンで、$getSelection()?.clone() によってスナップショットが値コピーとして保持されます。また、$setSelection のインポートも #restoreSelectionBeforeDrag の実装に合わせて追加されています。

設計判断

ドラッグの「入口」でスナップショットを取り、「出口」で復元する対称的な設計 が採用されています。

既存の dragCounter はネストしたドラッグイベント(子要素への再進入で dragleave が誤発火する問題への対処)の管理に使われていましたが、今回の修正はこの仕組みに乗っています。dragCounter === 1 になった最初の dragenter 時点だけ選択状態を保存することで、子要素を横断するドラッグ中に何度も上書きされることを防いでいます。また、ドロップされずに離脱した場合(dragCounter === 0)は null をセットしてクリアするため、次回のドラッグ操作に前回の選択状態が持ち越されることもありません。

#restoreSelectionBeforeDrag 内で復元後に即座に null をセットしている点も、一度の drop でスナップショットが消費されることを保証しており、状態の漏洩を防いでいます。

まとめ

ドラッグ操作中のブラウザイベントによるLexical内部状態の汚染を、「進入時スナップショット→離脱時クリア→ドロップ時復元」というライフサイクル管理で解決した修正です。既存の dragCounter 管理ロジックと整合した設計により、最小限のコード追加でブラウザの副作用を透過的に吸収しています。

記事メタデータ

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

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

対象読者への適合性 ✓ PASS

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

Lexicalの内部状態やブラウザイベントなど、専門知識を持つエンジニアを対象とした適切な技術レベルで書かれています。

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

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

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

Diff内容との照合 ⚠ WARNING

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

提供された`src/editor/command_dispatcher.js`のDiffが途中で途切れているため、記事内のコードブロックと完全な照合ができませんでした。しかし、記事に引用されているコードは、他のDiffファイル(`app/assets/javascript/lexxy.js`)の内容やPRの説明から判断して技術的に妥当であり、変更内容を正確に表現しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

`selectionchange`イベント、`CommandDispatcher`、`$getSelection`、`$setSelection`など、関連する技術用語が文脈に沿って正確に使用されています。

説明の技術的正確性 ✓ PASS

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

ドラッグ中の`selectionchange`イベントが原因であること、ドラッグのライフサイクル(enter/drop/leave)で選択状態を管理するという解決策の説明が、PRの内容とDiffから裏付けられており、技術的に正確です。

事実の突合 ✓ PASS

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

記事内のすべての主張(問題の原因、解決策、実装の詳細)は、PRのDescriptionおよびDiff内のコードによって裏付けられています。ハルシネーションは検出されませんでした。

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

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

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

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

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

記事のタイトルは、PRのタイトル「Fix dropped image position ignoring cursor in galleries」の内容を正確に要約しています。

外部知識の正確性 ✓ PASS

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

PR情報に記載のない、バージョンサポート状況やリリース日程などの外部知識は含まれていませんでした。

時間表現の正確性 ✓ PASS

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

「〜でしたバグを修正しました」といった表現が使われており、時間表現はPRのコンテキストと一致しています。