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>
);
}
カスタムプレビュー
ImageCropperFileSelector
の children
に任意の要素を渡すことで、カスタムプレビューを実装できます。
<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
Name | Type | Description |
---|---|---|
image | File | string | クロップする画像ファイル |
canvasWidth | number | キャンバスの横幅(ピクセル)。デフォルト: 400 |
aspectRatio | number | 画像のアスペクト比。デフォルト: 1 |
resultWidth | number | クロップ後の横幅(ピクセル) |
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
Name | Type | Description |
---|---|---|
onFileSelect | (file: File) => void | ファイルが選択された時のコールバック |
maxSize | number | 入力画像の最大サイズ(バイト)。デフォルト: 4MB |
className | string | カスタムクラス名(width と aspect-ratio を含む)。デフォルト: "w-full aspect-square" |
disabled | boolean | 無効化フラグ |
children | React.ReactNode | 子要素(プレビューなど) |
children の扱い
children
がある場合: それを表示(プレビュー画像など)children
がない場合: デフォルトの ImagePlus アイコンを表示
ImageCropperPreview
画像のプレビュー表示と削除ボタンを提供するコンポーネント。
Props
Name | Type | Description |
---|---|---|
src | string | プレビュー画像の URL |
alt | string | 代替テキスト |
onRemove | () => void | 削除ボタンが押された時のコールバック |
showRemoveButton | boolean | 削除ボタンを表示するか。デフォルト: 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-primary
や bg-primary/10
を調整してください。
使用例
- プロフィール画像のアップロード
- 商品画像の登録
- カバー画像の設定
- アバター画像の編集
- SNS 投稿の画像選択
参考
- react-avatar-editor - クロップ機能のベースライブラリ
- react-dropzone - ドロップゾーン機能のベースライブラリ