インラインメンションのドラッグ&ドロップで行内順序を変更できるように

basecamp/lexxy

Lexxy エディタでメンションを行内でドラッグし並び替えられるようになり、Basecamp 5 でも Basecamp 4 と同等のインライン編集体験が実現しました。

背景

Basecamp 5(Lexxy)では、チャット行内に埋め込まれた @mention をドラッグできず、編集時に位置を変更できないという問題がありました。これは **CustomActionTextAttachmentNode が生成する DOM が draggable 属性を持たず、data-lexical-node-key が付与されていなかったため、ドラッグ開始もドロップ復元も行えなかったことが根本原因です。さらに、既存の AttachmentDragAndDrop ハンドラはブロックレベルの figure.attachment のみを対象としており、インラインメンションに対しては処理をバイパスしていました。この制限により、メンションはテキストカーソルへのドロップ位置決定が不可能でした。結果として、ユーザーはメンションの位置調整を手作業で行うしかなく、編集効率が低下していました。

技術的な変更

DOM 属性の拡張CustomActionTextAttachmentNode#createDOMdraggable: truedata-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_COMMANDDROP_COMMANDCOMMAND_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.jsCustomAttachmentDragAndDropExtension を定義し、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.jsbaseExtensions 配列に新しい拡張を追加し、エディタインスタンスに自動的に組み込まれるようにしました。これにより、リッチテキストモードでだけドラッグ機能が有効化されます。

-      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 を新規追加し、caretFromPointcaretRect によって画面座標からテキストカーソル位置を安全に取得できるようにしました。これらはドラッグドロップ時のドロップ位置判定の基盤です。

+export function caretFromPoint(clientX, clientY) { /* 実装省略 */ }
+export function caretRect(node, offset) { /* 実装省略 */ }

テストの追加test/browser/tests/prompts/mention_drag_reorder.test.js に E2E テストを導入し、メンションがドラッグ可能であること、ドラッグ後にシリアライズされたテキスト内でメンション位置が変化することを検証しました。テストは Playwright で Chromium と Firefox の両方で成功し、機能の回帰防止が確保されています。

設計判断

インライン要素へのドラッグ属性付与は、既存のブロックレベルハンドラと競合しない最小限の変更として採用されました。draggable: truedata-lexical-node-key を同時に設定することで、Lexical の内部ノードマッピングとブラウザのドラッグ API を統合し、追加の DOM ラッパーを不要にしました。

高優先度コマンド登録により、AttachmentDragAndDrop のブロック処理がインラインメンションに介入せずにバイパスできるように設計されています。COMMAND_PRIORITY_HIGH を使用することで、リッチテキスト拡張が最初にイベントを取得し、AttachmentDragAndDropfigure.attachment に限定して処理を続行します。

拡張の有効化条件LexxyExtensionenabled プロパティでリッチテキストサポートの有無を判定し、軽量モードや純粋テキストエディタでは余計なリスナーが登録されないようにしています。これによりパフォーマンスへの影響を抑えつつ、機能提供を必要なコンテキストに限定しています。

スタイル分離は CSS 側でドラッグ中とドロップ先のビジュアルフィードバックを提供し、JavaScript ロジックから UI 表現を切り離すという設計原則に沿っています。これによりデザイン変更が容易で、アクセシビリティ対応もスタイルシート単位で管理できます。

まとめ

この PR でインラインメンションに draggable 属性と Lexical キーが付与され、CustomAttachmentDragAndDrop が高優先度でドラッグ&ドロップをハンドリングするようになったことで、行内のメンションを自由に並び替える操作が可能になりました。既存ブロック添付ハンドラとの非干渉、リッチテキスト環境だけで有効化する拡張設計、そして視覚フィードバックを提供する CSS 追加により、機能追加は最小限のコード変化で実装され、ユーザー体験とコードベースの保守性を同時に向上させています。

記事メタデータ

Generated by:
gpt-oss-120b for DiffDaily
LLM Trace:
33accc24

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

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
gpt-oss-120b for DiffDaily

Review Criteria:

記事構成 ✓ PASS

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

リード文、背景(Context)、技術的な変更(Technical Detail)、設計判断(任意)、まとめ(結論)の三部構成が明確に存在し、結論は単なる繰り返しではない。

カスタムMarkdown構文 ✓ PASS

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

コードブロックはすべて `言語:ファイルパス` の形式で正しく記述され、PRリンクも `[PR #1136](URL)` の形で正規化されている。

対象読者への適合性 ✓ PASS

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

エンジニア向けの詳細な実装解説で、初心者向けの過剰説明はなく適切な技術レベル。

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

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

各セクションは総論→各論→結論の流れで構成され、段落はトピックセンテンスで始まり一段落一トピック、長さも適切に分割されている。

Diff内容との照合 ✓ PASS

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

記事中のコードブロックは提供されたDiffと一致しており、抜粋・省略も内容理解に支障がない。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

使用されている技術用語はPRおよびDiffと一致し、誤用や不適切な表現は見られない。

説明の技術的正確性 ✓ PASS

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

技術的主張はDiffとPR記述に裏付けられ、因果関係も論理的に説明されている。

事実の突合 ✓ PASS

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

記事の全ての事実はPR情報またはDiffで裏付けられ、推測や捏造は存在しない。

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

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

PR番号 #1136 などの固有名詞は正確に記載されており、数値的誤りはない。

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

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

記事タイトルはPRタイトルの意味を正確に日本語化しており、一致している。

外部知識の正確性 ✓ PASS

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

バージョンサポートやリリース日程等、PRに記載のない外部知識は含まれていない。

時間表現の正確性 ✓ PASS

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

時間表現の歪曲はなく、PRの記述と整合している。