참고:
https://github.com/sekoyo/react-image-crop
https://github.com/sujjeee/shadcn-image-cropper/blob/main/src/components/image-cropper.tsx
추가 구현:
'use client';
import 'react-image-crop/dist/ReactCrop.css';
import { CropIcon } from 'lucide-react';
import React, { type SyntheticEvent, useState } from 'react';
import { FileWithPath, useDropzone } from 'react-dropzone';
import ReactCrop, {
centerCrop,
type Crop,
makeAspectCrop,
type PixelCrop,
} from 'react-image-crop';
import { toast } from 'sonner';
import { cn, base64ToFile } from '~/lib/utils';
import { Avatar, AvatarFallback, AvatarImage } from '~/ui/avatar';
import { Button } from '~/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '~/ui/dialog';
import LoadingIcon from '~/ui/icons';
export type FileWithPreview = FileWithPath & {
preview: string;
};
interface ImageCropperProps {
aspect?: number;
className?: string;
disabled?: boolean;
id?: string;
name?: string;
defaultImage?: string;
onChange?: (file: File) => void;
}
export function ImageCropper({
id,
name,
defaultImage,
aspect = 1,
className,
onChange,
}: ImageCropperProps) {
const imgRef = React.useRef<HTMLImageElement | null>(null);
const [file, setFile] = useState<FileWithPreview | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [crop, setCrop] = useState<Crop>();
const [croppedImageUrl, setCroppedImageUrl] = useState<string>('');
const [croppedImage, setCroppedImage] = useState<string>('');
const { getRootProps, getInputProps } = useDropzone({
maxFiles: 1,
onDrop: (acceptedFiles, rejectedFiles) => {
if (rejectedFiles.length > 0) {
rejectedFiles.forEach(({ file }) => {
toast.error(`${file.name} 파일이 유효하지 않습니다.`);
});
}
if (acceptedFiles.length < 1) {
toast.error('유효한 파일이 없습니다.', {
description: '유효한 이미지 파일을 선택해주세요.',
});
return;
}
const file = acceptedFiles[0];
const fileWithPreview = Object.assign(file, {
preview: URL.createObjectURL(file),
});
setFile(fileWithPreview);
setIsDialogOpen(true);
},
accept: {
'image/*': [],
},
});
function onImageLoad(e: SyntheticEvent<HTMLImageElement>) {
if (!aspect) {
return;
}
const { width, height } = e.currentTarget;
setCrop(centerAspectCrop(width, height, aspect));
}
function onCropComplete(crop: PixelCrop) {
if (imgRef.current && crop.width && crop.height) {
const croppedImageUrl = getCroppedImg(imgRef.current, crop);
setCroppedImageUrl(croppedImageUrl);
}
}
function getCroppedImg(image: HTMLImageElement, crop: PixelCrop): string {
const canvas = document.createElement('canvas');
const scaleX = image.naturalWidth / image.width;
const scaleY = image.naturalHeight / image.height;
canvas.width = crop.width * scaleX;
canvas.height = crop.height * scaleY;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.imageSmoothingEnabled = false;
ctx.drawImage(
image,
crop.x * scaleX,
crop.y * scaleY,
crop.width * scaleX,
crop.height * scaleY,
0,
0,
crop.width * scaleX,
crop.height * scaleY,
);
}
return canvas.toDataURL('image/png', 1.0);
}
async function applyCrop() {
try {
setCroppedImage(croppedImageUrl);
const croppedFile = await base64ToFile(croppedImageUrl, 'cropped.png');
onChange?.(croppedFile);
setIsDialogOpen(false);
} catch (e) {
console.error(e);
alert('Something went wrong!');
}
}
return (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<button
{...getRootProps()}
type="button"
className={cn(
'relative size-16 overflow-hidden rounded-full border outline-none',
'focus:ring-2 focus:ring-border focus:ring-offset-2',
'transition hover:opacity-80',
className,
)}
>
<input id={id} name={name} {...getInputProps()} />
<Avatar className="h-full w-full">
<AvatarImage src={croppedImage || defaultImage} alt="avatar" />
<AvatarFallback />
</Avatar>
</button>
<DialogContent>
<DialogHeader>
<DialogTitle>이미지 자르기</DialogTitle>
<DialogDescription>
이미지에 드래그해서 원하는 부분을 자르세요.
</DialogDescription>
</DialogHeader>
<div className="size-full max-h-[500px] overflow-x-hidden overflow-y-scroll">
<ReactCrop
crop={crop}
onChange={(_, percentCrop) => setCrop(percentCrop)}
onComplete={(c) => onCropComplete(c)}
aspect={aspect}
className="w-full"
>
<Avatar className="size-full rounded-none">
<AvatarImage
ref={imgRef}
className="aspect-auto size-full rounded-none object-contain"
alt="Image Cropper Shell"
src={file?.preview}
onLoad={onImageLoad}
/>
<AvatarFallback className="size-full min-h-[300px] rounded-none">
<LoadingIcon />
</AvatarFallback>
</Avatar>
</ReactCrop>
</div>
<DialogFooter>
<DialogClose asChild>
<Button
type="reset"
variant={'outline'}
onClick={() => {
setFile(null);
}}
>
닫기
</Button>
</DialogClose>
<Button type="submit" onClick={applyCrop}>
<CropIcon className="size-4" />
자르기
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function centerAspectCrop(
mediaWidth: number,
mediaHeight: number,
aspect: number,
): Crop {
return centerCrop(
makeAspectCrop(
{
unit: '%',
width: 50,
height: 50,
},
aspect,
mediaWidth,
mediaHeight,
),
mediaWidth,
mediaHeight,
);
}