添付ファイルのドラッグ&ドロップ機能を追加

basecamp/lexxy

Lexxyエディタに添付ファイル(画像・ファイル・動画)のドラッグ&ドロップによる再配置機能が実装されました。HTML5ドラッグイベントとLexicalのコマンドシステムを組み合わせ、ギャラリーへのマージ・リスト分割・ブロック間移動といった複数のドロップシナリオをアンドゥ/リドゥ対応で実現しています。

背景

LexxyはBasecamp製のリッチテキストエディタであり、添付ファイルの操作は主要なユースケースの一つです。これまで添付ファイルを別の位置に移動するには削除と再挿入が必要でしたが、直感的なドラッグ操作での再配置が求められていました。

外部ファイルのドロップ(ファイルシステムからの添付)と内部ノードの移動は、どちらも同じdropイベントとして届きます。この区別なしに実装すると、既存の外部ファイルアップロード処理と競合するため、両者を確実に分離する仕組みが必要でした。

技術的な変更

AttachmentDragAndDropクラスsrc/editor/attachments/drag_and_drop.jsに新設され、ドラッグ&ドロップのロジック全体を担います。このクラスはLexicalコマンドとDOM直接リスナーの2層構造で動作します。

内部ドラッグの識別には、カスタムMIMEタイプ application/x-lexxy-node-key が使われます。dragstart時にドラッグ元のLexicalノードキーをこのMIMEタイプでdataTransferに書き込み、drop時に読み出して対象ノードを特定します。ファイルシステムからのドロップはこのMIMEタイプを持たないため、両者の区別が確実に行えます。

Lexicalコマンドの登録はCOMMAND_PRIORITY_HIGHで行われています:

editor.registerCommand(DRAGSTART_COMMAND, (event) => this.#handleDragStart(event), COMMAND_PRIORITY_HIGH),
editor.registerCommand(DROP_COMMAND, (event) => this.#handleDrop(event), COMMAND_PRIORITY_HIGH),

dragoverdragendはrAFスロットリングが必要なためDOMリスナーで扱い、registerRootListenerでルート要素の付け替えに追従します。

ドロップ先の解決はDOM walkで行われ、3種類のシナリオを処理します:

  • ギャラリー内の画像へのドロップ: $findOrCreateGalleryForImageを使ってギャラリーへのマージまたはギャラリー内での並び替え
  • リストアイテムへのドロップ: $splitNodeでリストを分割して添付ファイルを挿入
  • トップレベルブロックへのドロップ: ブロック間での再配置

すべてのノード操作はeditor.update()内で実行されるため、アンドゥ/リドゥが自動的に機能します。ギャラリーの子が1つになった場合のアンラップや空ギャラリーの削除は、既存のトランスフォームが引き続き担当します。

既存のCommandDispatcherには、内部ドラッグを無視するガードが追加されました:

#isInternalDrag(event) {
  return event.dataTransfer?.types.includes("application/x-lexxy-node-key")
}

#handleDragEnter#handleDragLeave#handleDragOver#handleDropの各ハンドラの先頭でこのメソッドを呼び出し、内部ドラッグの場合は早期リターンします。これにより、外部ファイルドロップ用のカウンタ管理や選択状態の保存が内部ドラッグに干渉しなくなっています。

ActionTextAttachmentNode側では、DOM生成時にfigure.draggable = truefigure.dataset.lexicalNodeKey = this.__keyが付与されるようになりました。ノードキーをDOM属性として持たせることで、DOMイベントからLexicalノードへの逆引きが可能になっています。また、画像要素を直接返す代わりにdiv.attachment__containerでラップする構造変更も行われています。

ビジュアルフィードバックはCSSで実装されています:

  • .lexxy-dragging: ドラッグ中のソース要素にopacity: 0.4
  • .attachment[draggable]: cursor: grab
  • [class*="lexxy-drop-target--"]: ドロップターゲットの水平・垂直インジケータ(position: relativeをベースに疑似要素で表現)

設計判断

LexicalコマンドとDOM直接リスナーの使い分けが明示的に設計されています。dragstartdropはLexicalのコマンドシステムで扱うことで、@lexical/rich-textの既定ハンドラより前に処理を横取りできます。一方dragoverdragendはrAFスロットリングが必要なため、コマンドシステムを経由せずDOMリスナーとして登録しています。

クラスのライフサイクル管理にも注意が払われています。AttachmentDragAndDropはコンストラクタで登録したすべてのクリーンアップ関数を#cleanupFns配列に蓄積し、destroy()メソッドで一括解除します。AttachmentsExtensionmergeRegisterの戻り値として() => dragAndDrop.destroy()を渡すことで、Lexicalの登録解除の仕組みに統一しています。

return mergeRegister(
  editor.registerNodeTransform(ActionTextAttachmentNode, $extractAttachmentFromParagraph),
  editor.registerCommand(DELETE_CHARACTER_COMMAND, $collapseIntoGallery, COMMAND_PRIORITY_NORMAL),
  () => dragAndDrop.destroy()
)

テストはtest/browser/tests/attachments/attachment_drag_and_drop.test.jsとしてブラウザテストで実装されました。page.addInitScript()window.simulateDragAndDropヘルパーを注入し、DataTransferDragEventを直接構築することでPlaywrightから実際のドラッグシーケンスを再現できます。

まとめ

本PRはカスタムMIMEタイプによる内部・外部ドラッグの分離を軸に、LexicalのコマンドシステムとDOM直接リスナーを意図的に使い分けた実装です。すべての操作をeditor.update()に閉じ込めることで、複雑なドラッグシナリオをアンドゥ/リドゥの範囲に収め、既存のギャラリートランスフォームとの責務分担も維持しています。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
d201afcb

この記事は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リンク記法の正確性

ファイル名付きシンタックスハイライト、PR番号のリンク記法など、カスタムMarkdown構文がすべて正しく使用されています。

対象読者への適合性 ✓ PASS

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

Lexical、DOMイベント、rAFスロットリングといった専門用語を適切に用いており、専門知識を持つエンジニアという対象読者に適合した内容になっています。

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

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

各セクションとパラグラフが「総論→各論」の原則に従って構成されています。各段落の先頭がトピックセンテンスになっており、非常に可読性が高いです。

Diff内容との照合 ✓ PASS

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

記事内で引用されているコードブロックは、ファイルパス・内容ともに提供されたDiff情報と完全に一致しており、正確です。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

Lexicalコマンド、MIMEタイプ、DOMリスナーなど、関連技術の用語が文脈に沿って正確に使用されています。

説明の技術的正確性 ✓ PASS

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

内部ドラッグと外部ドロップの区別方法、イベントハンドラの使い分けといった技術的な説明は、DiffとPR Descriptionによって裏付けられており、正確かつ論理的です。

事実の突合 ✓ PASS

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

記事内のすべての主張がPRのDescriptionやDiff内のコード変更に基づいており、ハルシネーション(捏造や根拠のない推測)は見られません。

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

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

PR番号(#859)、ファイルパス、クラス名、カスタムMIMEタイプなどの固有名詞や値がすべて正確に記載されています。

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

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

記事のタイトル「添付ファイルのドラッグ&ドロップ機能を追加」は、PRの主題を的確に反映しています。

外部知識の正確性 ✓ PASS

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

PR情報に含まれない外部の知識(バージョン情報やリリース予定など)を持ち込んでおらず、事実に基づいた記述に徹しています。

時間表現の正確性 ✓ PASS

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

「実装されました」「追加されました」といった完了形の表現が使われており、PRでマージされた変更点を記述する上で時間表現は正確です。