129 lines
4.2 KiB
TypeScript
129 lines
4.2 KiB
TypeScript
import { useState, useCallback } from 'react';
|
|
import Cropper from 'react-easy-crop';
|
|
import type { Area, Point } from 'react-easy-crop';
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Slider } from '@/components/ui/slider';
|
|
import { ZoomIn, ZoomOut, RotateCcw } from 'lucide-react';
|
|
|
|
interface ImageCropDialogProps {
|
|
open: boolean;
|
|
imageSrc: string;
|
|
aspect: number;
|
|
title?: string;
|
|
onCancel: () => void;
|
|
onCrop: (croppedBlob: Blob) => void;
|
|
}
|
|
|
|
async function getCroppedBlob(imageSrc: string, pixelCrop: Area): Promise<Blob> {
|
|
const image = await createImageBitmap(await (await fetch(imageSrc)).blob());
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = pixelCrop.width;
|
|
canvas.height = pixelCrop.height;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) throw new Error('Canvas context unavailable');
|
|
ctx.drawImage(
|
|
image,
|
|
pixelCrop.x,
|
|
pixelCrop.y,
|
|
pixelCrop.width,
|
|
pixelCrop.height,
|
|
0,
|
|
0,
|
|
pixelCrop.width,
|
|
pixelCrop.height,
|
|
);
|
|
return new Promise<Blob>((resolve, reject) => {
|
|
canvas.toBlob((blob) => {
|
|
if (blob) resolve(blob);
|
|
else reject(new Error('Failed to create blob'));
|
|
}, 'image/jpeg', 0.92);
|
|
});
|
|
}
|
|
|
|
export function ImageCropDialog({ open, imageSrc, aspect, title = 'Crop Image', onCancel, onCrop }: ImageCropDialogProps) {
|
|
const [crop, setCrop] = useState<Point>({ x: 0, y: 0 });
|
|
const [zoom, setZoom] = useState(1);
|
|
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
|
|
const onCropComplete = useCallback((_: Area, areaPixels: Area) => {
|
|
setCroppedAreaPixels(areaPixels);
|
|
}, []);
|
|
|
|
const handleReset = () => {
|
|
setCrop({ x: 0, y: 0 });
|
|
setZoom(1);
|
|
};
|
|
|
|
const handleConfirm = async () => {
|
|
if (!croppedAreaPixels) return;
|
|
setIsProcessing(true);
|
|
try {
|
|
const blob = await getCroppedBlob(imageSrc, croppedAreaPixels);
|
|
onCrop(blob);
|
|
} finally {
|
|
setIsProcessing(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={(v) => { if (!v) onCancel(); }}>
|
|
<DialogContent className="sm:max-w-lg p-0 gap-0 overflow-hidden">
|
|
<DialogHeader className="px-5 pt-5 pb-3">
|
|
<DialogTitle className="text-base">{title}</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
{/* Cropper area */}
|
|
<div className="relative bg-black" style={{ height: 320 }}>
|
|
<Cropper
|
|
image={imageSrc}
|
|
crop={crop}
|
|
zoom={zoom}
|
|
aspect={aspect}
|
|
onCropChange={setCrop}
|
|
onZoomChange={setZoom}
|
|
onCropComplete={onCropComplete}
|
|
style={{
|
|
containerStyle: { borderRadius: 0 },
|
|
cropAreaStyle: { border: '2px solid hsl(var(--primary))' },
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Controls */}
|
|
<div className="px-5 py-4 space-y-3 border-t">
|
|
<div className="flex items-center gap-3">
|
|
<ZoomOut className="size-4 text-muted-foreground shrink-0" />
|
|
<Slider
|
|
min={1}
|
|
max={3}
|
|
step={0.01}
|
|
value={[zoom]}
|
|
onValueChange={([v]) => setZoom(v)}
|
|
className="flex-1"
|
|
/>
|
|
<ZoomIn className="size-4 text-muted-foreground shrink-0" />
|
|
</div>
|
|
<div className="flex justify-between items-center">
|
|
<Button variant="ghost" size="sm" onClick={handleReset} className="text-xs gap-1.5 h-8">
|
|
<RotateCcw className="size-3" />
|
|
Reset
|
|
</Button>
|
|
<p className="text-xs text-muted-foreground">Drag to reposition · Pinch or scroll to zoom</p>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter className="px-5 pb-5 gap-2 flex-row justify-end">
|
|
<Button variant="outline" onClick={onCancel} disabled={isProcessing} size="sm">
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleConfirm} disabled={isProcessing} size="sm">
|
|
{isProcessing ? 'Processing…' : 'Apply Crop'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|