ネイティブマルチ画像アップロードをギャラリー化するバッチAPIの導入

basecamp/lexxy

Lexxy が iOS/Android のネイティブブリッジで複数画像を選択した際に、画像が個別の添付としてスタックされる回帰を解消し、Web と同様に 1 つの画像ギャラリー として挿入できるようにしました。

背景

Web では すべてのアップロード入口Uploader.for(...) を通り、バッチに複数のプレビュー可能画像が含まれると GalleryUploader が選択されて ImageGalleryNode が生成されます。これに対し、ネイティブブリッジは各ファイルを個別に Contents#insertPendingAttachment(file) で渡すため isMultipleImageUpload が呼び出されず、ギャラリー形成が行われませんでした。Trix ではドキュメント全体を走査する attachmentGalleryFilter があったため回帰が起きませんでしたが、Lexxy では挿入時にのみギャラリー化が行われる設計のため、ネイティブ経路でギャラリーが失われたのです。

技術的な変更

バッチエントリポイントの追加

ContentsinsertPendingAttachments(files) を実装し、受け取った FileList 全体を Uploader.for(editorElement, files, { pending: true }) に渡すことで、Web と同じ GalleryUploader パスを通ります。アップロードノードは uploadUrl を持たない pending モードで生成され、ホストアプリがアップロードを管理できるようになります。

+  $createPendingUploadNode(file) {
+    return $createActionTextAttachmentUploadNode({
+      file,
+      uploadUrl: null,
+      blobUrlTemplate: this.editorElement.blobUrlTemplate,
+      contentType: file.type,
+    })
+  }
+
+  insertPendingAttachment(file) {
+    ...
+    const uploadNode = this.$createPendingUploadNode(file)
+    ...
+    return nodeKey ? this.#pendingAttachmentHandle(nodeKey) : null
+  }
+
+  insertPendingAttachments(files) {
+    const fileList = Array.from(files)
+    if (!this.editorElement.supportsAttachments || fileList.length === 0) return []
+    let nodeKeys = []
+    this.editor.update(() => {
+      const uploader = Uploader.for(this.editorElement, fileList, { pending: true })
+      uploader.$uploadFiles()
+      nodeKeys = (uploader.nodes ?? []).map(node => node.getKey())
+    })
+    return nodeKeys.map(nodeKey => this.#pendingAttachmentHandle(nodeKey))
+  }

Uploader のオプション拡張

Uploader.foroptions 引数を追加し、pending フラグが立っている場合は contents.$createPendingUploadNode を呼び出すよう分岐させました。これにより GalleryUploader でも同様の pending ノードが生成され、既存のギャラリー形成ロジックをそのまま再利用できます。

-  static for(editorElement, files) {
+  static for(editorElement, files, options = {}) {
     const UploaderKlass = GalleryUploader.handle(editorElement, files) ? GalleryUploader : Uploader
-    return new UploaderKlass(editorElement, files)
+    return new UploaderKlass(editorElement, files, options)
   }
@@
-  $createUploadNodes() {
-    this.nodes = this.files.map(file => this.contents.$createUploadNode(file))
+  $createUploadNodes() {
+    this.nodes = this.files.map(file => this.#createUploadNode(file))
   }
+
+  #createUploadNode(file) {
+    return this.options.pending
+      ? this.contents.$createPendingUploadNode(file)
+      : this.contents.$createUploadNode(file)
+  }

テスト追加

native_bridge_gallery.test.js でバッチ API の 3 つのシナリオ を検証しました。(1)複数画像が単一ギャラリーになること、(2)ファイルごとにハンドルが返り、マテリアライズしてもギャラリーが維持されること、(3)空バッチは何も挿入しないこと。

+test.describe("Native bridge gallery (batch)", () => {
+  ...
+  test("inserts multiple images as a single gallery", async ({ page, editor }) => {
+    await insertPendingBatch(editor, [ image("a.png"), image("b.png") ])
+    await editor.flush()
+    await expect(page.locator(".attachment-gallery")).toHaveCount(1)
+    await expect(page.locator(".attachment-gallery figure.attachment")).toHaveCount(2)
+  })
+  ...
+})

設計判断

既存ギャラリーコードの再利用

GalleryUploaderImageGalleryNode一切変更せず、新規ロジックは Uploader.for のオプション分岐$createPendingUploadNode のみです。これにより、Web とネイティブの挙動が完全に一致し、バグ修正が一元管理できます。

後方互換性の維持

単一ファイル向け insertPendingAttachment は残したままにし、古いネイティブクライアントが引き続き動作できるようにしました。新しいクライアントはバッチ API を呼び出す設計で、将来的に単一パスへ移行可能です。

アップロード所有権はホスト側に委譲

pending: true モードではノードに uploadUrl が設定されず、ホストアプリがアップロード進捗をハンドルします。これにより Lexxy 本体はアップロード実装に依存せず、プラットフォーム固有の最適化を維持できます。

まとめ

ネイティブブリッジが バッチで Uploader.forGalleryUploader の経路 を通るようにしたことで、画像の複数選択時に Web と同様の 画像ギャラリー が生成されます。変更は既存のギャラリー実装をそのまま再利用し、オプション分岐とハンドル抽象化だけで済ませたため、既存コードへの影響は最小限です。後方互換性も保たれ、将来的に単一エントリポイントへ統合できる設計になっています。

記事メタデータ

Generated by:
gpt-oss-120b for DiffDaily
LLM Trace:
962bceba

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

品質レビュー結果

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

Review Criteria:

記事構成 ✓ PASS

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

リード文があり、背景、技術的変更、設計判断(任意)、まとめの流れが明確に構成されています。

カスタムMarkdown構文 ✓ PASS

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

コードブロックは正しい ``言語:ファイルパス`` 形式で記述され、PRリンクも適切にマークダウン化されています。

対象読者への適合性 ✓ PASS

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

専門的なエンジニア向けの内容で、初心者向けの過度な説明はありません。

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

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

各段落はトピックセンテンスで始まり、一段落一トピックで構成。段落間は空行で区切られ、長さも適切です。

Diff内容との照合 ✓ PASS

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

記事内のコードブロックは提供されたDiffと一致しており、追加・変更点が正確に反映されています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

PRで使用されている用語(Uploader、GalleryUploader、pending など)を正しく用いています。

説明の技術的正確性 ✓ PASS

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

技術的な説明はPRの記述とコード変更に基づき、誤りや根拠のない主張はありません。

事実の突合 ✓ PASS

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

記事の全ての事実はPRのタイトル・説明・Diffで裏付けられており、捏造や推測はありません。

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

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

PR番号 #1111、テストシナリオ数 3 など数値は正確です。

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

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

記事タイトルはPRタイトルの内容を日本語で適切に表現しています。

外部知識の正確性 ✓ PASS

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

記事内にPR情報外のバージョン情報やリリース予定などは含まれていません。

時間表現の正確性 ✓ PASS

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

時間表現に関する記述はなく、PRと矛盾する表現もありません。