nino

Command Palette

Search for a command to run...

メニュー

Image Cropper

2025年01月15日

2025年01月15日

画像のアップロード、プレビュー、クロップ機能を提供する Compound Component パターンのコンポーネント群です。実装者が Dialog や Popover で柔軟にラップできる設計になっています。

画像をクロップ

インストール

pnpx shadcn@latest add https://www.dninomiya.com/r/image-cropper.json

構造

ImageCropper は以下の3つの独立したコンポーネントで構成されています:

  • ImageCropper - 画像のクロップ UI(AvatarEditor + Slider + ボタン)
  • ImageCropperFileSelector - ファイル選択とドロップゾーン機能
  • ImageCropperPreview - 画像のプレビュー表示と削除ボタン

使い方

基本的な使い方(Dialog)

"use client";

import { useState } from "react";
import {
  ImageCropper,
  ImageCropperFileSelector,
  ImageCropperPreview,
} from "@/registry/blocks/image-cropper";
import {
  Dialog,
  DialogContent,
} from "@workspace/ui/components/dialog";
import { Label } from "@workspace/ui/components/label";

export function ImageCropperExample() {
  const [open, setOpen] = useState(false);
  const [file, setFile] = useState<File | null>(null);
  const [preview, setPreview] = useState<string>("");

  return (
    <div className="flex flex-col gap-3">
      <Label>プロフィール画像</Label>
      
      <ImageCropperFileSelector
        onFileSelect={(file) => {
          setFile(file);
          setOpen(true);
        }}
        className="w-[200px] aspect-square"
      >
        {preview && (
          <ImageCropperPreview
            src={preview}
            onRemove={() => setPreview("")}
          />
        )}
      </ImageCropperFileSelector>

      <Dialog open={open} onOpenChange={setOpen}>
        <DialogContent>
          {file && (
            <ImageCropper
              image={file}
              canvasWidth={400}
              aspectRatio={1}
              resultWidth={600}
              onCrop={(dataUrl, blob) => {
                setPreview(dataUrl);
                setOpen(false);
                // blob をサーバーにアップロードすることも可能
              }}
              onCancel={() => setOpen(false)}
            />
          )}
        </DialogContent>
      </Dialog>
    </div>
  );
}

Popover で使用

"use client";

import { useState } from "react";
import {
  ImageCropper,
  ImageCropperFileSelector,
  ImageCropperPreview,
} from "@/registry/blocks/image-cropper";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@workspace/ui/components/popover";

export function ImageCropperPopoverExample() {
  const [open, setOpen] = useState(false);
  const [file, setFile] = useState<File | null>(null);
  const [preview, setPreview] = useState<string>("");

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger asChild>
        <div>
          <ImageCropperFileSelector
            onFileSelect={(file) => {
              setFile(file);
              setOpen(true);
            }}
            className="w-full aspect-video"
          >
            {preview && (
              <ImageCropperPreview
                src={preview}
                onRemove={() => setPreview("")}
              />
            )}
          </ImageCropperFileSelector>
        </div>
      </PopoverTrigger>
      
      <PopoverContent className="w-auto p-0" align="start">
        {file && (
          <ImageCropper
            image={file}
            canvasWidth={300}
            aspectRatio={16/9}
            resultWidth={800}
            onCrop={(dataUrl, blob) => {
              setPreview(dataUrl);
              setOpen(false);
            }}
            onCancel={() => setOpen(false)}
          />
        )}
      </PopoverContent>
    </Popover>
  );
}

カスタムプレビュー

ImageCropperFileSelectorchildren に任意の要素を渡すことで、カスタムプレビューを実装できます。

<ImageCropperFileSelector
  onFileSelect={(file) => {/* ... */}}
  className="w-full aspect-square"
>
  {preview && (
    <div className="relative w-full h-full">
      <img src={preview} className="w-full h-full object-cover" />
      <Badge className="absolute top-2 right-2">認証済み</Badge>
    </div>
  )}
</ImageCropperFileSelector>

クロップなしで使用

クロップ機能が不要な場合は、ImageCropperFileSelector のみを使用できます。

"use client";

import { useState } from "react";
import {
  ImageCropperFileSelector,
  ImageCropperPreview,
} from "@/registry/blocks/image-cropper";

export function SimpleImageUpload() {
  const [preview, setPreview] = useState<string>("");

  return (
    <ImageCropperFileSelector
      onFileSelect={(file) => {
        const reader = new FileReader();
        reader.onload = (e) => {
          setPreview(e.target?.result as string);
        };
        reader.readAsDataURL(file);
      }}
      className="w-full aspect-square"
    >
      {preview && (
        <ImageCropperPreview
          src={preview}
          onRemove={() => setPreview("")}
        />
      )}
    </ImageCropperFileSelector>
  );
}

React Hook Form と FormField で使用

React Hook Form と shadcn/ui の Form コンポーネントを使用して、フォームバリデーションと統合できます。

"use client";

import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import {
  ImageCropper,
  ImageCropperFileSelector,
  ImageCropperPreview,
} from "@/registry/blocks/image-cropper";
import {
  Dialog,
  DialogContent,
} from "@workspace/ui/components/dialog";
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@workspace/ui/components/form";
import { Button } from "@workspace/ui/components/button";
import { Input } from "@workspace/ui/components/input";

// フォームスキーマ
const profileFormSchema = z.object({
  username: z.string().min(2, {
    message: "ユーザー名は2文字以上である必要があります。",
  }),
  profileImage: z.string().optional(),
});

type ProfileFormValues = z.infer<typeof profileFormSchema>;

export function ProfileForm() {
  const [open, setOpen] = useState(false);
  const [file, setFile] = useState<File | null>(null);

  const form = useForm<ProfileFormValues>({
    resolver: zodResolver(profileFormSchema),
    defaultValues: {
      username: "",
      profileImage: "",
    },
  });

  function onSubmit(data: ProfileFormValues) {
    console.log("フォームデータ:", data);
    // ここでサーバーに送信
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
        <FormField
          control={form.control}
          name="username"
          render={({ field }) => (
            <FormItem>
              <FormLabel>ユーザー名</FormLabel>
              <FormControl>
                <Input placeholder="山田太郎" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name="profileImage"
          render={({ field }) => (
            <FormItem>
              <FormLabel>プロフィール画像</FormLabel>
              <FormControl>
                <ImageCropperFileSelector
                  onFileSelect={(file) => {
                    setFile(file);
                    setOpen(true);
                  }}
                  className="w-[200px] aspect-square"
                >
                  {field.value && (
                    <ImageCropperPreview
                      src={field.value}
                      onRemove={() => field.onChange("")}
                    />
                  )}
                </ImageCropperFileSelector>
              </FormControl>
              <FormDescription>
                プロフィールに表示される画像をアップロードしてください。
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />

        <Button type="submit">保存</Button>
      </form>

      <Dialog open={open} onOpenChange={setOpen}>
        <DialogContent className="max-w-md">
          {file && (
            <ImageCropper
              image={file}
              canvasWidth={400}
              aspectRatio={1}
              resultWidth={600}
              onCrop={(dataUrl, blob) => {
                form.setValue("profileImage", dataUrl);
                setOpen(false);
              }}
              onCancel={() => setOpen(false)}
            />
          )}
        </DialogContent>
      </Dialog>
    </Form>
  );
}

必須バリデーション付きフォーム

画像を必須フィールドにする場合:

const profileFormSchema = z.object({
  username: z.string().min(2),
  profileImage: z.string().min(1, {
    message: "プロフィール画像を選択してください。",
  }),
});

// フォームフィールドは同じ実装でOK
// バリデーションエラーは自動的に FormMessage に表示されます

カスタムバリデーション

画像のファイルサイズや形式を検証する場合:

const profileFormSchema = z.object({
  username: z.string().min(2),
  profileImage: z
    .string()
    .refine((val) => val.length > 0, {
      message: "プロフィール画像を選択してください。",
    })
    .refine(
      (val) => {
        // Data URL のサイズをチェック(約 1MB)
        return val.length < 1400000;
      },
      {
        message: "画像サイズが大きすぎます。",
      }
    ),
});

コンポーネント

ImageCropper

画像のクロップ UI を提供するコンポーネント。Dialog や Popover でラップして使用します。

Props

NameTypeDescription
imageFile | stringクロップする画像ファイル
canvasWidthnumberキャンバスの横幅(ピクセル)。デフォルト: 400
aspectRationumber画像のアスペクト比。デフォルト: 1
resultWidthnumberクロップ後の横幅(ピクセル)
onCrop(dataUrl: string, blob: Blob) => voidクロップ完了時のコールバック
onCancel() => voidキャンセル時のコールバック

onCrop の返り値

  • dataUrl: JPEG 形式の Data URL。プレビュー表示に使用
  • blob: JPEG 形式の Blob。サーバーへのアップロードに使用
onCrop={(dataUrl, blob) => {
  // プレビュー表示
  setPreview(dataUrl);
  
  // サーバーへアップロード
  const formData = new FormData();
  formData.append("file", blob, "cropped-image.jpg");
  await fetch("/api/upload", {
    method: "POST",
    body: formData,
  });
}}

ImageCropperFileSelector

ファイル選択とドロップゾーン機能を提供するコンポーネント。常にドラッグ&ドロップが有効です。

Props

NameTypeDescription
onFileSelect(file: File) => voidファイルが選択された時のコールバック
maxSizenumber入力画像の最大サイズ(バイト)。デフォルト: 4MB
classNamestringカスタムクラス名(width と aspect-ratio を含む)。デフォルト: "w-full aspect-square"
disabledboolean無効化フラグ
childrenReact.ReactNode子要素(プレビューなど)

children の扱い

  • children がある場合: それを表示(プレビュー画像など)
  • children がない場合: デフォルトの ImagePlus アイコンを表示

ImageCropperPreview

画像のプレビュー表示と削除ボタンを提供するコンポーネント。

Props

NameTypeDescription
srcstringプレビュー画像の URL
altstring代替テキスト
onRemove() => void削除ボタンが押された時のコールバック
showRemoveButtonboolean削除ボタンを表示するか。デフォルト: true

アスペクト比の例

// 正方形(1:1)- アバター、プロフィール画像
<ImageCropperFileSelector className="w-full aspect-square" />

// 横長(16:9)- サムネイル、カバー画像
<ImageCropperFileSelector className="w-full aspect-video" />

// 縦長(3:4)- ポートレート
<ImageCropperFileSelector className="w-full aspect-[3/4]" />

// 円形風(1:1 + border-radius)
<ImageCropperFileSelector className="w-full aspect-square rounded-full" />

// カスタムサイズ
<ImageCropperFileSelector className="w-[240px] aspect-square" />

サーバーへのアップロード

Blob を使用してサーバーにアップロードする例:

// app/actions.ts
"use server";

import { writeFile } from "fs/promises";
import { join } from "path";

export async function uploadImage(formData: FormData) {
  const file = formData.get("file") as File;
  const bytes = await file.arrayBuffer();
  const buffer = Buffer.from(bytes);
  
  const path = join(process.cwd(), "public/uploads", file.name);
  await writeFile(path, buffer);
  
  return { success: true, path: `/uploads/${file.name}` };
}

// コンポーネント
import { uploadImage } from "./actions";

<ImageCropper
  onCrop={async (dataUrl, blob) => {
    const formData = new FormData();
    formData.append("file", blob, "profile.jpg");
    
    const result = await uploadImage(formData);
    console.log("アップロード完了:", result.path);
  }}
/>

実装のヒント

画像の品質とサイズ

resultWidth を適切に設定することで、画像のサイズと品質をコントロールできます:

// 小さい画像(アバター用)
<ImageCropper resultWidth={200} />

// 中程度の画像(プロフィール用)
<ImageCropper resultWidth={600} />

// 大きい画像(カバー画像用)
<ImageCropper resultWidth={1200} />

ファイルサイズ制限

maxSize prop で受け入れるファイルサイズを制限できます:

// 1MB
<ImageCropperFileSelector maxSize={1024 * 1024} />

// 4MB(デフォルト)
<ImageCropperFileSelector maxSize={1024 * 1024 * 4} />

// 10MB
<ImageCropperFileSelector maxSize={1024 * 1024 * 10} />

ドラッグ&ドロップの視覚フィードバック

ImageCropperFileSelector は自動的にドラッグ中の視覚フィードバックを提供します。カスタマイズする場合は、CSS で border-primarybg-primary/10 を調整してください。

使用例

  • プロフィール画像のアップロード
  • 商品画像の登録
  • カバー画像の設定
  • アバター画像の編集
  • SNS 投稿の画像選択

参考