メインコンテンツへスキップ
Agent実践2/26/2026, 12:22:56 PM

ブログエディタに画像アップロードを組み込んだら、書く速度が変わった話

ブログエディタに画像アップロードを組み込んだら、書く速度が変わった話
目次(18件)

結論として、画像を記事に入れるまでの手順が多いと、書くリズムが止まります。 Storage を開いてアップロードして、URL をコピーして、Markdown に貼り付けて——この3ステップを毎回やるのは地味にきつい。 この記事では、エディタ内で画像を貼るだけで自動アップロード + Markdown 挿入される仕組みを、Supabase Storage と React で作った過程を記録します。

この記事で得られること

  • 画像アップロードの3経路(Ctrl+V / ドラッグ&ドロップ / ボタン)の実装方法
  • Supabase Storage の設定と RLS ポリシーの設定方法
  • アップロード関数を1つに統一して、本文画像もアイキャッチも同じ仕組みで回す設計
  • レビューで見つかった3つの落とし穴と修正内容

BEFORE: 手作業で URL を転記する運用

もともとの画像挿入フローはこうでした。

  1. Supabase Dashboard → Storage → post-images バケットを開く
  2. 画像ファイルをアップロード
  3. 公開 URL をコピー
  4. エディタに戻って ![alt](URL) を手入力

問題点:

  • 画面を行き来するので書く流れが途切れる
  • URL のコピーミス(パスの typo、バケット名の間違い)が起きやすい
  • アイキャッチ画像も同じ手順を踏む必要がある

結果として、「画像なしで公開 → あとで差し替えよう → 忘れる」が頻発していました。

AFTER: エディタから直接アップロード

改善後は、3つの方法で画像を挿入できます。

  1. Ctrl+V(クリップボード貼り付け): スクショを撮ってそのまま貼るだけ
  2. ドラッグ&ドロップ: ファイルをエディタに投げ込む
  3. Image ボタン: ツールバーのボタンからファイルを選択

いずれも操作後に自動でアップロードが走り、完了すると ![ファイル名](公開URL) が本文に挿入されます。 アイキャッチ画像も同じ Upload ボタンで URL が自動入力されます。

解消された問題:

  • 画面の行き来 → エディタ内で完結
  • URL のコピーミス → 自動挿入で人的ミスを排除
  • 「あとで差し替えよう → 忘れる」 → その場で入れられるので先送りが起きない

Before/After: 手作業のURL転記 → エディタから直接アップロード

設計: 共通アップロード関数を1つ作る

まず、すべての入力経路が呼び出す共通関数を1つだけ作りました。

// src/lib/supabase/uploadImage.ts
const BUCKET = "post-images";
const MAX_SIZE = 5 * 1024 * 1024; // 5MB

export type UploadResult =
  | { ok: true; url: string }
  | { ok: false; error: string };

export async function uploadImage(file: File): Promise<UploadResult> {
  // バリデーション
  if (!file.type.startsWith("image/")) {
    return { ok: false, error: "画像ファイルのみアップロードできます" };
  }
  if (file.size > MAX_SIZE) {
    return { ok: false, error: "ファイルサイズは5MB以下にしてください" };
  }

  // パス生成: YYYY/MM/timestamp-filename
  const now = new Date();
  const prefix = `${now.getFullYear()}/${String(now.getMonth() + 1).padStart(2, "0")}`;
  const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, "_");
  const path = `${prefix}/${Date.now()}-${safeName}`;

  // アップロード
  const { error } = await supabase.storage
    .from(BUCKET)
    .upload(path, file, { contentType: file.type, upsert: false });

  if (error) return { ok: false, error: error.message };

  const { data } = supabase.storage.from(BUCKET).getPublicUrl(path);
  return { ok: true, url: data.publicUrl };
}

ポイント:

  • Result 型: ok: true | false で成功・失敗を明示。呼び出し側が if (result.ok) で分岐できる
  • パス設計: YYYY/MM/timestamp-filename で衝突を防止。同じファイル名でも timestamp が違えば別パス
  • ファイル名のサニタイズ: 日本語ファイル名はアンダースコアに置換して URL 安全に

Supabase Storage の設定

バケット作成

Supabase Dashboard で post-images バケットを作成し、Public に設定しました。 Public バケットにすると、アップロードされた画像に認証なしでアクセスできます(ブログの公開画像なので問題なし)。

RLS(Row Level Security)

アップロード・削除は管理者のみ、閲覧は全員に許可するポリシーを設定しました。

-- 誰でも閲覧可
CREATE POLICY "Public read" ON storage.objects
  FOR SELECT USING (bucket_id = 'post-images');

-- 管理者のみアップロード
CREATE POLICY "Admin insert" ON storage.objects
  FOR INSERT WITH CHECK (
    bucket_id = 'post-images'
    AND auth.uid() = 'あなたのUUID'::uuid
  );

-- 管理者のみ削除
CREATE POLICY "Admin delete" ON storage.objects
  FOR DELETE USING (
    bucket_id = 'post-images'
    AND auth.uid() = 'あなたのUUID'::uuid
  );

auth.uid() を固定 UUID で制限するのは、個人ブログで管理者が1人しかいない場合のシンプルな方法です。

実装: エディタへの3経路

1. Ctrl+V(クリップボード貼り付け)

onPaste イベントで clipboardData.items を走査し、画像があれば横取りしてアップロードします。

const handlePaste = useCallback(
  async (e: React.ClipboardEvent) => {
    const items = e.clipboardData.items;
    for (const item of items) {
      if (item.type.startsWith("image/")) {
        e.preventDefault(); // テキスト貼り付けを止める
        const file = item.getAsFile();
        if (!file) return;
        await handleImageUpload(file);
        return;
      }
    }
    // 画像がなければ通常のテキスト貼り付け
  },
  [handleImageUpload],
);

テキストを貼り付けるときは何もしません。画像があるときだけ preventDefault() で割り込みます。

2. ドラッグ&ドロップ

onDroponDragOver を textarea に設定します。

const handleDrop = useCallback(
  async (e: React.DragEvent) => {
    e.preventDefault(); // ← これをハンドラの先頭に置く
    const files = e.dataTransfer.files;
    if (files.length === 0) return;
    const file = files[0];
    if (!file.type.startsWith("image/")) return;
    await handleImageUpload(file);
  },
  [handleImageUpload],
);
<textarea
  onDrop={handleDrop}
  onDragOver={(e) => e.preventDefault()}
/>

onDragOverpreventDefault() しないと、ブラウザがファイルを新しいタブで開こうとします。

3. Image ボタン(ファイルピッカー)

ツールバーに Image ボタンを置き、非表示の <input type="file"> を経由します。

const fileInputRef = useRef<HTMLInputElement>(null);

<button onClick={() => fileInputRef.current?.click()}>
  Image
</button>
<input
  ref={fileInputRef}
  type="file"
  accept="image/*"
  className="hidden"
  onChange={(e) => {
    const file = e.target.files?.[0];
    if (file) onImageUpload(file);
    e.target.value = ""; // 同じファイルを再選択可能にする
  }}
/>

共通のアップロードハンドラ

3経路すべてがこの関数を呼びます。プレースホルダを先に挿入して「アップロード中」を見せ、完了後に差し替えます。

const handleImageUpload = useCallback(
  async (file: File) => {
    const ta = textareaRef.current;
    if (!ta) return;

    // 一意なプレースホルダを挿入
    const uid = Date.now();
    const placeholder = `![アップロード中..._${uid}]()`;
    insertText(ta, placeholder + "\n");
    syncFromTextarea();

    const result = await uploadImage(file);
    const current = textareaRef.current?.value ?? "";

    if (result.ok) {
      const alt = file.name.replace(/\.[^.]+$/, "");
      onChange(current.replace(placeholder, `![${alt}](${result.url})`));
    } else {
      onChange(current.replace(placeholder + "\n", ""));
      alert(result.error);
    }
  },
  [onChange],
);

アイキャッチ画像にも同じ仕組みを適用

記事本文だけでなく、アイキャッチ(カバー画像)の URL 入力欄にも Upload ボタンを追加しました。 URL の手入力との併用ができるので、外部画像の URL を直接貼ることもできます。

<div className="flex gap-2">
  <input
    className="flex-1 rounded border p-2"
    value={coverImageUrl}
    onChange={(e) => setCoverImageUrl(e.target.value)}
    placeholder="https://... (画像URL)"
  />
  <button
    disabled={coverUploading}
    onClick={() => coverFileRef.current?.click()}
  >
    {coverUploading ? "uploading..." : "Upload"}
  </button>
</div>

uploadImage() を呼んで、成功したら URL を state にセットするだけ。 新規投稿ページと編集ページの両方に同じパターンで入れました。

レビューで見つかった3つの落とし穴

実装後のレビューで指摘された問題と修正内容です。

1. プレースホルダの競合

問題: 複数の画像を連続でアップロードすると、プレースホルダが同じ文字列になり、replace が誤った位置を差し替える。

修正: Date.now() でユニーク ID を付与し、プレースホルダを一意にした。

// Before
![アップロード中...]()

// After
![アップロード中..._1740567890123]()

2. alt テキストが固定値

問題: すべての画像の alt が「アップロード中...」のままだった。

修正: ファイル名から拡張子を除去したものをデフォルト alt に使用。

const alt = file.name.replace(/\.[^.]+$/, "");
// "screenshot.png" → alt="screenshot"

3. drop の preventDefault 順序

問題: e.preventDefault() が画像チェックの後にあったため、画像以外のファイルをドロップするとブラウザが別タブでファイルを開いてしまう。

修正: e.preventDefault() をハンドラの先頭に移動。

まとめ

KPI: 画像アップロード統合の効果

画像アップロードをエディタに組み込んだことで、「書く → 画像を入れる → 書く」のループが途切れなくなりました。

設計上のポイントを振り返ると:

  1. 共通関数を1つ: uploadImage() にバリデーション・パス生成・エラーハンドリングを集約。本文もアイキャッチも同じ関数を呼ぶ
  2. 3経路は入口が違うだけ: Ctrl+V / D&D / ボタン → すべて handleImageUpload(file) に合流する
  3. プレースホルダで即座にフィードバック: アップロード完了を待たずに「処理中」を表示し、完了後に差し替える

Agent を使った開発では、「こういう機能がほしい」と伝えれば、ベースとなるコードは出てきます。 ただし、レビューで見つかった3つの落とし穴のように、実機で動かして初めて分かる問題は必ずあります。 実装 → テスト → レビュー → 修正のサイクルを回すのが、Agent 開発でも変わらず大事でした。