添付ファイルのドラッグ&ドロップ機能を追加
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),
dragoverとdragendは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 = trueとfigure.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直接リスナーの使い分けが明示的に設計されています。dragstartとdropはLexicalのコマンドシステムで扱うことで、@lexical/rich-textの既定ハンドラより前に処理を横取りできます。一方dragoverとdragendはrAFスロットリングが必要なため、コマンドシステムを経由せずDOMリスナーとして登録しています。
クラスのライフサイクル管理にも注意が払われています。AttachmentDragAndDropはコンストラクタで登録したすべてのクリーンアップ関数を#cleanupFns配列に蓄積し、destroy()メソッドで一括解除します。AttachmentsExtensionはmergeRegisterの戻り値として() => 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ヘルパーを注入し、DataTransferとDragEventを直接構築することでPlaywrightから実際のドラッグシーケンスを再現できます。
まとめ
本PRはカスタムMIMEタイプによる内部・外部ドラッグの分離を軸に、LexicalのコマンドシステムとDOM直接リスナーを意図的に使い分けた実装です。すべての操作をeditor.update()に閉じ込めることで、複雑なドラッグシナリオをアンドゥ/リドゥの範囲に収め、既存のギャラリートランスフォームとの責務分担も維持しています。