Image Crop Dialog component
Install
npx shadcn add https://ui.happenings.social/r/image-crop-dialog.json
Or with namespace
npx shadcn add @happenings/image-crop-dialog
Preview
Registry block: dialog with react-easy-crop for image cropping before upload.
src/components/image-crop-dialog.tsx
"use client";
import * as React from "react";
import Cropper from "react-easy-crop";
import type { Area } from "react-easy-crop";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
import { Button } from "./ui/button";
import { Slider } from "./ui/slider";
export interface CropConfig {
aspect?: number | undefined;
cropShape?: "rect" | "round";
minZoom?: number;
maxZoom?: number;
quality?: number;
outputType?: string;
}
export interface ImageCropDialogProps {
file: File | null;
config: CropConfig;
onCropComplete: (croppedFile: File) => void;
onCancel: () => void;
title?: string;
}
export function ImageCropDialog({
file,
config,
onCropComplete,
onCancel,
title = "Crop image",
}: ImageCropDialogProps) {
const [imageSrc, setImageSrc] = React.useState<string | null>(null);
const [crop, setCrop] = React.useState({ x: 0, y: 0 });
const [zoom, setZoom] = React.useState(1);
const [croppedAreaPixels, setCroppedAreaPixels] = React.useState<Area | null>(
null,
);
const [processing, setProcessing] = React.useState(false);
const minZoom = config.minZoom ?? 1;
const maxZoom = config.maxZoom ?? 3;
const quality = config.quality ?? 0.92;
React.useEffect(() => {
if (!file) {
setImageSrc(null);
return;
}
const url = URL.createObjectURL(file);
setImageSrc(url);
setCrop({ x: 0, y: 0 });
setZoom(1);
setCroppedAreaPixels(null);
return () => URL.revokeObjectURL(url);
}, [file]);
function handleCropChange(location: { x: number; y: number }) {
setCrop(location);
}
function handleZoomChange(z: number) {
setZoom(z);
}
function handleCropComplete(_: Area, areaPixels: Area) {
setCroppedAreaPixels(areaPixels);
}
async function handleConfirm() {
if (!imageSrc || !croppedAreaPixels || !file) return;
setProcessing(true);
try {
const outputType = config.outputType ?? file.type ?? "image/jpeg";
const blob = await getCroppedImg(
imageSrc,
croppedAreaPixels,
outputType,
quality,
);
const croppedFile = new File([blob], file.name, { type: outputType });
onCropComplete(croppedFile);
} finally {
setProcessing(false);
}
}
return (
<Dialog
open={!!file && !!imageSrc}
onOpenChange={(open) => {
if (!open) onCancel();
}}
>
<DialogContent className="sm:max-w-lg" showCloseButton={false}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="relative aspect-square w-full overflow-hidden rounded-lg bg-zinc-950">
{imageSrc && (
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={config.aspect}
cropShape={config.cropShape ?? "rect"}
showGrid={true}
onCropChange={handleCropChange}
onZoomChange={handleZoomChange}
onCropComplete={handleCropComplete}
/>
)}
</div>
<div className="flex items-center gap-3 px-1">
<span className="text-xs text-muted-foreground">Zoom</span>
<Slider
min={minZoom * 100}
max={maxZoom * 100}
value={[zoom * 100]}
onValueChange={([v]) => setZoom(v / 100)}
className="flex-1"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel} disabled={processing}>
Cancel
</Button>
<Button onClick={handleConfirm} disabled={processing || !croppedAreaPixels}>
{processing ? "Cropping..." : "Crop & Continue"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
async function getCroppedImg(
imageSrc: string,
pixelCrop: Area,
outputType: string,
quality: number,
): Promise<Blob> {
const image = await loadImage(imageSrc);
const canvas = document.createElement("canvas");
canvas.width = pixelCrop.width;
canvas.height = pixelCrop.height;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Canvas 2D context unavailable");
ctx.drawImage(
image,
pixelCrop.x,
pixelCrop.y,
pixelCrop.width,
pixelCrop.height,
0,
0,
pixelCrop.width,
pixelCrop.height,
);
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) resolve(blob);
else reject(new Error("Canvas toBlob failed"));
},
outputType,
quality,
);
});
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.addEventListener("load", () => resolve(img));
img.addEventListener("error", (e) => reject(e));
img.src = src;
});
}