'use client'; import type { ComponentProps, CSSProperties, MouseEvent, ReactNode, RefObject, SyntheticEvent, } from 'react'; import type { PercentCrop, PixelCrop, ReactCropProps } from 'react-image-crop'; import { createContext, useCallback, useContext, useEffect, useRef, useState, } from 'react'; import { CropIcon, RotateCcwIcon } from 'lucide-react'; import { Slot } from 'radix-ui'; import ReactCrop, { centerCrop, makeAspectCrop } from 'react-image-crop'; import { Button, cn } from '@gib/ui'; import 'react-image-crop/dist/ReactCrop.css'; // Demo import { UploadIcon } from 'lucide-react'; const centerAspectCrop = ( mediaWidth: number, mediaHeight: number, aspect: number | undefined, ): PercentCrop => centerCrop( aspect ? makeAspectCrop( { unit: '%', width: 90, }, aspect, mediaWidth, mediaHeight, ) : { x: 0, y: 0, width: 90, height: 90, unit: '%' }, mediaWidth, mediaHeight, ); const getCroppedPngImage = async ( imageSrc: HTMLImageElement, scaleFactor: number, pixelCrop: PixelCrop, maxImageSize: number, ): Promise => { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); if (!ctx) { throw new Error('Context is null, this should never happen.'); } const scaleX = imageSrc.naturalWidth / imageSrc.width; const scaleY = imageSrc.naturalHeight / imageSrc.height; ctx.imageSmoothingEnabled = false; canvas.width = pixelCrop.width; canvas.height = pixelCrop.height; ctx.drawImage( imageSrc, pixelCrop.x * scaleX, pixelCrop.y * scaleY, pixelCrop.width * scaleX, pixelCrop.height * scaleY, 0, 0, canvas.width, canvas.height, ); const croppedImageUrl = canvas.toDataURL('image/png'); const response = await fetch(croppedImageUrl); const blob = await response.blob(); if (blob.size > maxImageSize) { return await getCroppedPngImage( imageSrc, scaleFactor * 0.9, pixelCrop, maxImageSize, ); } return croppedImageUrl; }; interface ImageCropContextType { file: File; maxImageSize: number; imgSrc: string; crop: PercentCrop | undefined; completedCrop: PixelCrop | null; imgRef: RefObject; onCrop?: (croppedImage: string) => void; reactCropProps: Omit; handleChange: (pixelCrop: PixelCrop, percentCrop: PercentCrop) => void; handleComplete: ( pixelCrop: PixelCrop, percentCrop: PercentCrop, ) => Promise; onImageLoad: (e: SyntheticEvent) => void; applyCrop: () => Promise; resetCrop: () => void; } const ImageCropContext = createContext(null); const useImageCrop = () => { const context = useContext(ImageCropContext); if (!context) { throw new Error('ImageCrop components must be used within ImageCrop'); } return context; }; export type ImageCropProps = { file: File; maxImageSize?: number; onCrop?: (croppedImage: string) => void; children: ReactNode; onChange?: ReactCropProps['onChange']; onComplete?: ReactCropProps['onComplete']; } & Omit; export const ImageCrop = ({ file, maxImageSize = 1024 * 1024 * 5, onCrop, children, onChange, onComplete, ...reactCropProps }: ImageCropProps) => { const imgRef = useRef(null); const [imgSrc, setImgSrc] = useState(''); const [crop, setCrop] = useState(); const [completedCrop, setCompletedCrop] = useState(null); const [initialCrop, setInitialCrop] = useState(); useEffect(() => { const reader = new FileReader(); reader.addEventListener('load', () => setImgSrc(reader.result?.toString() || ''), ); reader.readAsDataURL(file); }, [file]); const onImageLoad = useCallback( (e: SyntheticEvent) => { const { width, height } = e.currentTarget; const newCrop = centerAspectCrop(width, height, reactCropProps.aspect); setCrop(newCrop); setInitialCrop(newCrop); }, [reactCropProps.aspect], ); const handleChange = (pixelCrop: PixelCrop, percentCrop: PercentCrop) => { setCrop(percentCrop); onChange?.(pixelCrop, percentCrop); }; const handleComplete = async ( pixelCrop: PixelCrop, percentCrop: PercentCrop, ) => { setCompletedCrop(pixelCrop); onComplete?.(pixelCrop, percentCrop); }; const applyCrop = async () => { if (!(imgRef.current && completedCrop)) { return; } const croppedImage = await getCroppedPngImage( imgRef.current, 1, completedCrop, maxImageSize, ); onCrop?.(croppedImage); }; const resetCrop = () => { if (initialCrop) { setCrop(initialCrop); setCompletedCrop(null); } }; const contextValue: ImageCropContextType = { file, maxImageSize, imgSrc, crop, completedCrop, imgRef, onCrop, reactCropProps, handleChange, handleComplete, onImageLoad, applyCrop, resetCrop, }; return ( {children} ); }; export interface ImageCropContentProps { style?: CSSProperties; className?: string; } export const ImageCropContent = ({ style, className, }: ImageCropContentProps) => { const { imgSrc, crop, handleChange, handleComplete, onImageLoad, imgRef, reactCropProps, } = useImageCrop(); const shadcnStyle = { '--rc-border-color': 'var(--color-border)', '--rc-focus-color': 'var(--color-primary)', } as CSSProperties; return ( {imgSrc && ( crop )} ); }; export type ImageCropApplyProps = ComponentProps<'button'> & { asChild?: boolean; }; export const ImageCropApply = ({ asChild = false, children, onClick, ...props }: ImageCropApplyProps) => { const { applyCrop } = useImageCrop(); const handleClick = async (e: MouseEvent) => { await applyCrop(); onClick?.(e); }; if (asChild) { return ( {children} ); } return ( ); }; export type ImageCropResetProps = ComponentProps<'button'> & { asChild?: boolean; }; export const ImageCropReset = ({ asChild = false, children, onClick, ...props }: ImageCropResetProps) => { const { resetCrop } = useImageCrop(); const handleClick = (e: MouseEvent) => { resetCrop(); onClick?.(e); }; if (asChild) { return ( {children} ); } return ( ); }; // Keep the original Cropper component for backward compatibility export type CropperProps = Omit & { file: File; maxImageSize?: number; onCrop?: (croppedImage: string) => void; onChange?: ReactCropProps['onChange']; }; export const Cropper = ({ onChange, onComplete, onCrop, style, className, file, maxImageSize, ...props }: CropperProps) => ( ); export const Demo = () => { const [file, setFile] = useState(null); const [croppedImage, setCroppedImage] = useState(null); const handleFileChange = (e: React.ChangeEvent) => { const selectedFile = e.target.files?.[0]; if (selectedFile) { setFile(selectedFile); setCroppedImage(null); } }; return (
{!file ? ( ) : (
{croppedImage && (
Cropped result: Cropped
)}
)}
); };