HB DevTools

Technical Docs

Image Diff Table の設計

GitHub PR用のBefore/After画像比較表生成ツールの設計を解説。Image MD Link変換・カスタムラベル・@dnd-kit によるD&D並び替え・Cloudinary画像アップロード・ReactMarkdownプレビューの実装詳細を紹介します。

1. 設計思想 — GitHub PRレビューの課題と解決アプローチ

GitHub PRに画像の比較表を貼る際、手動でMarkdownを書くのは煩雑です。<img> タグの記法や表の構文を毎回調べながら書くと、 ミスが生じやすく、レビュアーも変更内容を把握しにくくなります。

このツールは、以下の3点を解決することを目的として設計しています。

  • Markdownの自動生成: 項目名とBefore/AfterのURLを入力するだけで、 GitHub Flavored Markdown(GFM)準拠のテーブルを即座に生成します。<img> タグの width 属性もスライダーで直感的に調整できます。
  • 画像URLの取得ハードル解消: ローカルファイルをそのままドラッグ&ドロップ(またはアップロードボタン)で選択すると、 Cloudinary に自動アップロードし、取得したパブリックURLをそのまま入力欄に挿入します。 URL を手動で取得・貼り付ける手間が不要です。
  • 行順序の自由な変更: PR説明文の流れに合わせて比較行の順序を変えたいケースに対応するため、 D&D(ドラッグ&ドロップ)による行の並び替えをサポートしています。

2. 技術アーキテクチャ

本ツールは2層で構成されています。Server Action は不要なため、Layer 4 は存在しません。

  • 純粋関数層src/lib/tools/image-diff-table.ts): 3つの純粋関数を実装しています。generateImageDiffTable は行データ(DiffRow[])・画像幅・ カスタムラベル(beforeLabel/afterLabel)を受け取り GFMテーブル文字列を返します。generateImageMdLink はURLを受け取り![デフォルト](url) 形式のMarkdown文字列を返します。validateImageDiffTableInput は入力値のバリデーションを行います。 いずれも副作用なしの純粋関数として設計されており、エラーは Union 型 ({ success: false, message: string })で返します。
  • Client Component層ImageDiffTableClient.tsx): D&Dコンテキスト・行の追加削除・Cloudinaryアップロード・ localStorage永続化・タブ切り替えを管理します。 純粋関数はレンダリングのたびに呼び出され(const result = generateImageDiffTable(...))、 Markdownはリアルタイムに更新されます。

Cloudinary へのアップロードはブラウザから直接 Cloudinary の Upload API を呼び出すため、Server Action は不要です。 APIキー(cloud_nameupload_preset)はNEXT_PUBLIC_ プレフィックス付き環境変数として管理しており、unsigned upload preset を使用することでAPIシークレットを クライアントに持たせることなくアップロードを実現しています。

3. D&D並び替えの仕組み(@dnd-kit)

行の並び替えには @dnd-kit/core@dnd-kit/sortable を使用しています。 HTML5ネイティブのDrag and Drop APIと比べ、タッチデバイス対応・ アクセシビリティ(キーボード操作)・アニメーション管理が容易なため採用しています。

  • センサー設定:PointerSensor(マウス・タッチ)とKeyboardSensor(キーボード、sortableKeyboardCoordinates 使用)の 2種類を useSensors で組み合わせています。 キーボードによるアクセシブルな並び替えが可能です。
  • ドラッグ中の視覚フィードバック:useSortable が返す isDragging フラグを利用して、 ドラッグ中の行に opacity-90 shadow-lg ring-2 ring-primary/40 を適用し、 現在ドラッグ中の要素を視覚的に区別しています。
  • 並び替え後の状態更新:handleDragEndactive.idover.id を受け取り、@dnd-kit/sortablearrayMove ユーティリティで 配列を安全にミュートせず再構築します。 各行に crypto.randomUUID() で生成した一意 id を持たせることで、 DnD の識別子として利用しています。

4. 画像アップロード機能(Cloudinary連携)

ローカル画像ファイルを選択すると、Cloudinary の Upload API に直接アップロードし、 返却された secure_url を入力欄に自動挿入します。

  • Unsigned Upload Preset: Cloudinaryでは通常、アップロードに署名付きリクエストが必要ですが、 Unsigned Upload Preset を使うことでAPIシークレットなしのクライアント直接アップロードが可能です。 プリセット名と cloud_name を NEXT_PUBLIC_ 環境変数で管理し、 ブラウザから FormDatafetch のみで実装しています。
  • アップロード中のUX: スロットキー(`${rowId}-${field}`)をuploadingSlot state で管理し、 アップロード中の特定スロットのみ Loader2(スピナー)を表示して無効化します。 他の行やスロットは引き続き操作可能です。
  • ファイル形式の制限:accept="image/jpeg,image/png,image/webp,image/gif"input[type=file] に指定しています。 ファイル選択ダイアログで対応外ファイルをフィルタリングします。
  • hidden file input パターン: ファイル選択の input[type=file] はスタイリングの制約が大きいため、aria-hidden="true" で非表示にした上で、 専用のアップロードアイコンボタンの onClick からfileInputRef.current?.click() でプログラム的に開くパターンを採用しています。

5. リアルタイムプレビュー(ReactMarkdown + rehype-raw)

出力パネルは「Markdown」タブと「Preview」タブの2つを持ちます。 Previewタブでは生成されたMarkdownをリアルタイムにレンダリングして確認できます。

  • rehype-raw の採用理由: 生成するMarkdownには <img src="..." width="N"> という 生のHTMLタグが含まれます。react-markdown はデフォルトで HTMLタグを無視するため、rehype-raw プラグインを追加して MarkdownパイプラインにHTMLパースを組み込んでいます。 これにより、Previewタブで実際の画像が表示されます。
  • remarkGfm の採用理由:remark-gfm によりGitHub Flavored Markdownのテーブル構文(|---|---|)を 正しくレンダリングします。標準の CommonMark ではテーブルが未サポートのため必須です。
  • テーブルスタイリング:ReactMarkdown が生成する <table><td><th> には Tailwind クラスが当たらないため、 親要素に [&_table]:border-collapse [&_td]:border [&_td]:p-2 のような CSS子孫セレクタを用いたカスタマイズを適用しています。

6. localStorage による状態永続化

ページをリロードしても設定値が失われないよう、localStorage に画像幅・Beforeラベル・Afterラベルを保存しています。 画像URLは揮発性データ(アップロード済み画像が削除される可能性がある)のため 保存対象外とし、行構造もリロード後はデフォルト(1行)から始まる設計としています。

  • ハイドレーション対策: Next.js の SSR では初回レンダリングがサーバーサイドで行われるため、localStorage にアクセスできません。hydrated フラグを用意し、マウント後(useEffect 実行後)にのみlocalStorage への保存を行うことで、 サーバー/クライアント間のレンダリング不一致(hydration mismatch)を防いでいます。
  • 2段階の useEffect: 1つ目の useEffect(依存配列 [])でマウント時にデータをロードし、hydratedtrue にセットします。 2つ目の useEffect(依存配列 [imageWidth, beforeLabel, afterLabel, hydrated])でhydratedtrue のときのみ保存を実行します。 この順序制御により、マウント直後にデフォルト値で上書きされる問題を防いでいます。
  • 破損データの無視:JSON.parsetry/catch で包み、 ストレージが破損していても例外を握りつぶしてデフォルト状態で起動します。

8. 品質管理

Image Diff Table は純粋関数テストと E2E テストの両方でカバーしています。

  • 純粋関数テスト(Vitest)src/lib/tools/__tests__/image-diff-table.test.ts):generateImageMdLink に4件、generateImageDiffTable に13件、validateImageDiffTableInput に8件、計25件のテストを実装しています。 正常系(GFMテーブル生成・複数行・width反映・空URL時のプレースホルダー・ 空項目名時のデフォルト表示・カスタムラベル反映・ラベル省略時のデフォルトフォールバック)、 異常系(空配列・行数超過)、境界値(MIN/MAX行数・MIN/MAX画像幅)を網羅しています。
  • E2Eテスト(Playwright)e2e/image-diff-table.spec.ts): 9件のシナリオで主要なUIフローを検証しています。 Image MD Link Converter(URL入力→出力表示・空時のdisabled・コピーToast通知)と Image Diff Table(ページタイトル表示・テキスト入力のリアルタイム反映・ 行数変更による動的行追加削除・コピーToast通知・デフォルト状態でのコピーボタン活性・ 画像幅スライダー操作・カスタムラベルのヘッダー反映)をカバーしています。
  • Playwright の Strict Mode 対策: 複数行に同じ aria-label(例: Before画像URL)を持つ要素が存在するため、getByLabel でそのまま取得すると Strict Mode 違反になります。 E2Eテストでは .first() で先頭要素に絞り込んでいます。 また、Markdownテキストエリアと同一ラベルをコピーボタンが部分一致するケースではpage.getByRole('textbox', { name: '...' }) でロールを指定して一意に特定しています。