OSドラッグ&ドロップのファイル挿入位置がズレる問題を修正
OSのファイルマネージャーからファイルをドラッグ&ドロップした際、添付ファイルがドロップ位置ではなくドラッグ開始前のカーソル位置に挿入されてしまうバグを修正しました。document.caretRangeFromPoint() を用いてドロップ座標からDOMセレクションを明示的に更新する仕組みを追加しています。
背景
ブラウザのドラッグ操作には、DOM選択(window.getSelection())が更新されないという特性があります。OS上のファイルをエディタにドラッグする際、ブラウザはドラッグカーソル(ビジュアルキャレット)を描画しますが、DOMのセレクションは変更しません。そのため、#handleDrop が uploadFiles() を直接呼び出すと、Lexicalはドラッグ開始前の古いDOMセレクション位置に添付ファイルを挿入してしまいます。
このバグの根本原因はPlaywrightによるテストでも見逃されやすい性質を持っています。Playwrightが生成する合成 DragEvent はDOMセレクションを更新してしまうため、テスト環境では再現せずバグが隠蔽されます。PR本文によると、再現するにはドロップイベントをインターセプトして意図的にDOMセレクションを元の位置に戻し、ドロップロジックをイベント座標とともに呼び出す必要があります。
技術的な変更
今回の修正の核心は、src/editor/contents.js に追加された dropFiles() メソッドと #moveSelectionToPoint() プライベートヘルパーです。#handleDrop が uploadFiles() を直接呼び出す代わりに、まず座標から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.caretRangeFromPoint と document.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 に記録されており、同種のバグに対する知見が蓄積されています。