インラインメンションのドラッグ&ドロップで行内順序を変更できるように
Lexxy エディタでメンションを行内でドラッグし並び替えられるようになり、Basecamp 5 でも Basecamp 4 と同等のインライン編集体験が実現しました。
背景
Basecamp 5(Lexxy)では、チャット行内に埋め込まれた @mention をドラッグできず、編集時に位置を変更できないという問題がありました。これは **CustomActionTextAttachmentNode が生成する DOM が draggable 属性を持たず、data-lexical-node-key が付与されていなかったため、ドラッグ開始もドロップ復元も行えなかったことが根本原因です。さらに、既存の AttachmentDragAndDrop ハンドラはブロックレベルの figure.attachment のみを対象としており、インラインメンションに対しては処理をバイパスしていました。この制限により、メンションはテキストカーソルへのドロップ位置決定が不可能でした。結果として、ユーザーはメンションの位置調整を手作業で行うしかなく、編集効率が低下していました。
技術的な変更
DOM 属性の拡張:CustomActionTextAttachmentNode#createDOM が draggable: true と data-lexical-node-key を付与するよう変更され、メンション要素がブラウザのドラッグ対象として認識されます。これにより、ドラッグイベントが正しく発火し、Lexical がノードキーで対象を特定できるようになりました。
-const figure = createElement(this.tagName, { "content-type": this.contentType, "data-lexxy-decorator": true })
+const figure = createElement(this.tagName, { "content-type": this.contentType, "data-lexxy-decorator": true, draggable: true })
+figure.dataset.lexicalNodeKey = this.__key
インラインドラッグコントローラの導入:src/editor/attachments/custom/drag_and_drop.js に新規クラス CustomAttachmentDragAndDrop を実装し、DRAGSTART_COMMAND と DROP_COMMAND を COMMAND_PRIORITY_HIGH でハンドリングします。ドラッグ開始時にノードキーを記録し、dragover / dragend イベントでビジュアルインジケータを管理。ドロップ時は caretFromPoint ヘルパーでテキストカーソル位置を算出し、$createRangeSelectionFromDom を用いてその位置にメンションノードを再配置します。
+import { $createRangeSelectionFromDom, $getNodeByKey, $setSelection, COMMAND_PRIORITY_HIGH, DRAGSTART_COMMAND, DROP_COMMAND } from "lexical"
+// 省略
+this.#listeners.track(
+ editor.registerCommand(DRAGSTART_COMMAND, (event) => this.#handleDragStart(event), COMMAND_PRIORITY_HIGH),
+ editor.registerCommand(DROP_COMMAND, (event) => this.#handleDrop(event), COMMAND_PRIORITY_HIGH)
+)
拡張機構の登録:src/extensions/custom_attachment_drag_and_drop_extension.js で CustomAttachmentDragAndDropExtension を定義し、LexxyExtension を継承して enabled をリッチテキスト対応時に限定します。defineExtension によりエディタ起動時に CustomAttachmentDragAndDrop のインスタンスが生成され、エディタ破棄時にクリーンアップされます。
+export class CustomAttachmentDragAndDropExtension extends LexxyExtension {
+ get enabled() { return this.editorElement.supportsRichText }
+ get lexicalExtension() {
+ return defineExtension({
+ name: "lexxy/custom-attachment-drag-and-drop",
+ register: (editor) => {
+ const dragAndDrop = new CustomAttachmentDragAndDrop(editor)
+ return () => dragAndDrop.destroy()
+ }
+ })
+ }
+}
エディタ要素への組み込み:src/elements/editor.js の baseExtensions 配列に新しい拡張を追加し、エディタインスタンスに自動的に組み込まれるようにしました。これにより、リッチテキストモードでだけドラッグ機能が有効化されます。
- PreventLexicalTripleClickExtension
+ PreventLexicalTripleClickExtension,
+ CustomAttachmentDragAndDropExtension
CSS の視覚フィードバック:app/assets/stylesheets/lexxy-editor.css に [data-lexxy-decorator][draggable] のカーソルを grab にし、ドラッグ中は opacity: 0.4、ドロップ位置を示す .lexxy-drop-caret のスタイルを追加しました。これによりユーザーはドラッグ状態とドロップ先を視覚的に把握できます。
+[data-lexxy-decorator][draggable] { cursor: grab; }
+[data-lexxy-decorator].lexxy-dragging { opacity: 0.4; }
+.lexxy-drop-caret { background-color: var(--lexxy-focus-ring-color); border-radius: 1px; inline-size: 2px; pointer-events: none; position: fixed; }
内部判定ロジックの調整:src/editor/command_dispatcher.js の内部ドラッグ判定を、拡張された MIME タイプ全体を対象に変更しました。application/x-lexxy- プレフィックスを持つすべてのタイプを認識することで、新規ドラッグハンドラが正しく検出されます。
- return event.dataTransfer?.types.includes("application/x-lexxy-node-key")
+ return event.dataTransfer?.types.some((type) => type.startsWith("application/x-lexxy-"))
カーソル取得ヘルパー:src/helpers/caret_helpers.js を新規追加し、caretFromPoint と caretRect によって画面座標からテキストカーソル位置を安全に取得できるようにしました。これらはドラッグドロップ時のドロップ位置判定の基盤です。
+export function caretFromPoint(clientX, clientY) { /* 実装省略 */ }
+export function caretRect(node, offset) { /* 実装省略 */ }
テストの追加:test/browser/tests/prompts/mention_drag_reorder.test.js に E2E テストを導入し、メンションがドラッグ可能であること、ドラッグ後にシリアライズされたテキスト内でメンション位置が変化することを検証しました。テストは Playwright で Chromium と Firefox の両方で成功し、機能の回帰防止が確保されています。
設計判断
インライン要素へのドラッグ属性付与は、既存のブロックレベルハンドラと競合しない最小限の変更として採用されました。draggable: true と data-lexical-node-key を同時に設定することで、Lexical の内部ノードマッピングとブラウザのドラッグ API を統合し、追加の DOM ラッパーを不要にしました。
高優先度コマンド登録により、AttachmentDragAndDrop のブロック処理がインラインメンションに介入せずにバイパスできるように設計されています。COMMAND_PRIORITY_HIGH を使用することで、リッチテキスト拡張が最初にイベントを取得し、AttachmentDragAndDrop が figure.attachment に限定して処理を続行します。
拡張の有効化条件は LexxyExtension の enabled プロパティでリッチテキストサポートの有無を判定し、軽量モードや純粋テキストエディタでは余計なリスナーが登録されないようにしています。これによりパフォーマンスへの影響を抑えつつ、機能提供を必要なコンテキストに限定しています。
スタイル分離は CSS 側でドラッグ中とドロップ先のビジュアルフィードバックを提供し、JavaScript ロジックから UI 表現を切り離すという設計原則に沿っています。これによりデザイン変更が容易で、アクセシビリティ対応もスタイルシート単位で管理できます。
まとめ
この PR でインラインメンションに draggable 属性と Lexical キーが付与され、CustomAttachmentDragAndDrop が高優先度でドラッグ&ドロップをハンドリングするようになったことで、行内のメンションを自由に並び替える操作が可能になりました。既存ブロック添付ハンドラとの非干渉、リッチテキスト環境だけで有効化する拡張設計、そして視覚フィードバックを提供する CSS 追加により、機能追加は最小限のコード変化で実装され、ユーザー体験とコードベースの保守性を同時に向上させています。