OSドラッグ&ドロップのファイル挿入位置がズレる問題を修正

basecamp/lexxy

OSのファイルマネージャーからファイルをドラッグ&ドロップした際、添付ファイルがドロップ位置ではなくドラッグ開始前のカーソル位置に挿入されてしまうバグを修正しました。document.caretRangeFromPoint() を用いてドロップ座標からDOMセレクションを明示的に更新する仕組みを追加しています。

背景

ブラウザのドラッグ操作には、DOM選択(window.getSelection())が更新されないという特性があります。OS上のファイルをエディタにドラッグする際、ブラウザはドラッグカーソル(ビジュアルキャレット)を描画しますが、DOMのセレクションは変更しません。そのため、#handleDropuploadFiles() を直接呼び出すと、Lexicalはドラッグ開始前の古いDOMセレクション位置に添付ファイルを挿入してしまいます。

このバグの根本原因はPlaywrightによるテストでも見逃されやすい性質を持っています。Playwrightが生成する合成 DragEvent はDOMセレクションを更新してしまうため、テスト環境では再現せずバグが隠蔽されます。PR本文によると、再現するにはドロップイベントをインターセプトして意図的にDOMセレクションを元の位置に戻し、ドロップロジックをイベント座標とともに呼び出す必要があります。

技術的な変更

今回の修正の核心は、src/editor/contents.js に追加された dropFiles() メソッドと #moveSelectionToPoint() プライベートヘルパーです。#handleDropuploadFiles() を直接呼び出す代わりに、まず座標からDOMセレクションを更新してから添付ファイルをアップロードするという順序が保証されます。

src/editor/contents.js に追加された dropFiles()#moveSelectionToPoint() の実装:

dropFiles(files, { clientX, clientY } = {}) {
  this.#moveSelectionToPoint(clientX, clientY)
  this.uploadFiles(files, { selectLast: true })
}

#moveSelectionToPoint(clientX, clientY) {
  if (clientX == null || clientY == null) return

  let range

  if (document.caretRangeFromPoint) {
    range = document.caretRangeFromPoint(clientX, clientY)
  } else if (document.caretPositionFromPoint) {
    const position = document.caretPositionFromPoint(clientX, clientY)
    if (position) {
      range = document.createRange()
      range.setStart(position.offsetNode, position.offset)
      range.collapse(true)
    }
  }

  if (!range) return

  const domSelection = window.getSelection()
  domSelection.removeAllRanges()
  domSelection.addRange(range)
}

src/editor/command_dispatcher.js では、#handleDrop の呼び出しが以下のように変更されています:

// 変更前
this.contents.uploadFiles(files, { selectLast: true })

// 変更後
this.contents.dropFiles(files, { clientX: event.clientX, clientY: event.clientY })

#moveSelectionToPoint()document.caretRangeFromPointdocument.caretPositionFromPoint の両方に対応しており、後者では caretPositionFromPoint の結果から Range オブジェクトを手動構築しています。両APIが利用できない場合は早期リターンして処理を続行するため、デグレードを最小限に抑える設計になっています。

なお、同じPRで dispatchInsertCodeBlock()editor.update() でラップする修正が含まれており、コードブロック挿入コマンドがLexicalのエディタ更新サイクルの外で実行されていた問題も合わせて修正されています。

設計判断

uploadFiles() を直接修正するのではなく、dropFiles() というラッパーを追加する方式が選ばれました。これにより、既存の uploadFiles() の呼び出しパスには影響を与えず、ドラッグ&ドロップ固有のDOM操作を責務として dropFiles() に分離できます。uploadFiles() はプログラムからのファイル挿入にも使われるため、uploadFiles() 自体に座標処理を組み込むと、ドラッグ以外のユースケースとの分離が崩れます。

#moveSelectionToPoint() のクロスブラウザ対応は、2つのAPIを明示的にフォールバックする形で実装されています。標準化の過渡期にある複数のAPIを順番に試行し、いずれも利用できない場合は早期リターンして処理を続行する設計は、環境への依存を局所化しつつ堅牢性を保ちます。

まとめ

DOMセレクションを更新しないブラウザのドラッグ挙動という仕様上の落とし穴を、caretRangeFromPoint() による明示的な座標→セレクション変換で解消した修正です。責務を dropFiles() に分離するアプローチにより、既存の添付ファイルアップロードパスへの影響を最小化しながら問題を解決しています。また、合成イベントがバグを隠蔽するPlaywrightの特性を踏まえた専用のリグレッションテスト手法も .claude/skills/bugs-reproducer.md に記録されており、同種のバグに対する知見が蓄積されています。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
98225c17

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

品質レビュー結果

Review Status:
リトライ後承認
Review Count:
2回 (改善を経て承認)
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

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

リード文(総論)、背景・技術詳細・設計判断(各論)、まとめ(結論)の3部構成が明確で、ガイドラインを完全に満たしています。

カスタムMarkdown構文 ✓ PASS

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

ファイル名付きシンタックスハイライト(```javascript:filepath```)とGitHubのPRリンク記法([#824](URL))が正しく使用されています。

対象読者への適合性 ✓ PASS

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

DOMセレクション、Lexical、Playwrightといった専門用語を前提としており、対象読者であるエンジニアに適した技術レベルと表現で書かれています。

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

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

各セクションが総論→各論の構成になっており、各段落もトピックセンテンスで始まり、1段落1トピックの原則が守られています。可読性が非常に高いです。

Diff内容との照合 ✓ PASS

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

記事内で引用されている`src/editor/contents.js`と`src/editor/command_dispatcher.js`のコードは、提供されたDiffの内容と完全に一致しています。ファイルパスも正確です。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

`document.caretRangeFromPoint`、`DOMセレクション`、`合成DragEvent`などの技術用語が、PRの文脈に沿って正確に使用されています。

説明の技術的正確性 ✓ PASS

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

「ブラウザがドラッグ中にDOMセレクションを更新しない」という根本原因の説明や、`dropFiles`メソッドによる解決策の説明は、技術的に正確で論理的です。

事実の突合 ✓ PASS

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

記事内のすべての主張(バグの原因、修正内容、Playwrightのテストに関する挙動、関連するドキュメント更新など)は、PRのTitle、Description、Diffの内容によって裏付けられています。ハルシネーションは見られません。

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

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

PR番号(#824)や、引用されているファイル名、メソッド名などの固有名詞はすべて正確です。

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

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

記事のタイトル「OSドラッグ&ドロップのファイル挿入位置がズレる問題を修正」は、PRの主題「Fix dropped attachments landing at stale cursor position」を的確に反映しています。

外部知識の正確性 ✓ PASS

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

記事の内容は提供されたPR情報に限定されており、バージョン情報やサポート状況といった外部知識の追記はありません。

時間表現の正確性 ✓ PASS

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

PR情報と矛盾するような時間表現(「既に」「まもなく」など)は使用されていません。