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を受け取り形式の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_name と upload_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を適用し、 現在ドラッグ中の要素を視覚的に区別しています。 - 並び替え後の状態更新:
handleDragEndでactive.idとover.idを受け取り、@dnd-kit/sortableのarrayMoveユーティリティで 配列を安全にミュートせず再構築します。 各行にcrypto.randomUUID()で生成した一意idを持たせることで、 DnD の識別子として利用しています。
4. 画像アップロード機能(Cloudinary連携)
ローカル画像ファイルを選択すると、Cloudinary の Upload API に直接アップロードし、 返却された secure_url を入力欄に自動挿入します。
- Unsigned Upload Preset: Cloudinaryでは通常、アップロードに署名付きリクエストが必要ですが、 Unsigned Upload Preset を使うことでAPIシークレットなしのクライアント直接アップロードが可能です。 プリセット名と cloud_name を
NEXT_PUBLIC_環境変数で管理し、 ブラウザからFormDataとfetchのみで実装しています。 - アップロード中のUX: スロットキー(
`${rowId}-${field}`)をuploadingSlotstate で管理し、 アップロード中の特定スロットのみ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(依存配列[])でマウント時にデータをロードし、hydratedをtrueにセットします。 2つ目のuseEffect(依存配列[imageWidth, beforeLabel, afterLabel, hydrated])でhydratedがtrueのときのみ保存を実行します。 この順序制御により、マウント直後にデフォルト値で上書きされる問題を防いでいます。 - 破損データの無視:
JSON.parseはtry/catchで包み、 ストレージが破損していても例外を握りつぶしてデフォルト状態で起動します。
7. Image MD Link Converter
ページ上部に配置された「Image MD Link 変換」セクションは、 GitHub の Markdown に画像を埋め込む際に必要な 形式の文字列をワンクリックで生成します。
- 純粋関数による生成:
generateImageMdLink(url)はURLを受け取り、 前後スペースをトリムした上で形式の文字列を返します。 URLが空またはスペースのみの場合は{ success: false, message: ... }を返し、 コピーボタンをdisabledにすることでユーザーの誤操作を防ぎます。 - カスタムラベル(Beforeラベル / Afterラベル): 比較表のヘッダーカラム名はデフォルトで "Before" / "After" ですが、 Controls エリアの入力フィールドで任意の文字列に変更できます。 例えば「ライトモード / ダークモード」や「SP / PC」など、PRの文脈に合わせたラベルを設定でき、 変更はMarkdownにリアルタイムに反映されます。 空文字またはスペースのみの場合はデフォルト値(
DEFAULT_BEFORE_LABEL/DEFAULT_AFTER_LABEL)に フォールバックします。設定値はlocalStorageに永続化されます。 - コピーUX:
useCopyToClipboardフックを使用しており、 コピー成功時にボタンがチェックマーク表示に切り替わり、 Toast通知(sonner)で「MD Linkをコピーしました」を表示します。
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: '...' })でロールを指定して一意に特定しています。