436 lines
10 KiB
TypeScript
436 lines
10 KiB
TypeScript
'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<string> => {
|
|
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<HTMLImageElement | null>;
|
|
onCrop?: (croppedImage: string) => void;
|
|
reactCropProps: Omit<ReactCropProps, 'onChange' | 'onComplete' | 'children'>;
|
|
handleChange: (pixelCrop: PixelCrop, percentCrop: PercentCrop) => void;
|
|
handleComplete: (
|
|
pixelCrop: PixelCrop,
|
|
percentCrop: PercentCrop,
|
|
) => Promise<void>;
|
|
onImageLoad: (e: SyntheticEvent<HTMLImageElement>) => void;
|
|
applyCrop: () => Promise<void>;
|
|
resetCrop: () => void;
|
|
}
|
|
|
|
const ImageCropContext = createContext<ImageCropContextType | null>(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<ReactCropProps, 'onChange' | 'onComplete' | 'children'>;
|
|
|
|
export const ImageCrop = ({
|
|
file,
|
|
maxImageSize = 1024 * 1024 * 5,
|
|
onCrop,
|
|
children,
|
|
onChange,
|
|
onComplete,
|
|
...reactCropProps
|
|
}: ImageCropProps) => {
|
|
const imgRef = useRef<HTMLImageElement | null>(null);
|
|
const [imgSrc, setImgSrc] = useState<string>('');
|
|
const [crop, setCrop] = useState<PercentCrop>();
|
|
const [completedCrop, setCompletedCrop] = useState<PixelCrop | null>(null);
|
|
const [initialCrop, setInitialCrop] = useState<PercentCrop>();
|
|
|
|
useEffect(() => {
|
|
const reader = new FileReader();
|
|
reader.addEventListener('load', () =>
|
|
setImgSrc(reader.result?.toString() || ''),
|
|
);
|
|
reader.readAsDataURL(file);
|
|
}, [file]);
|
|
|
|
const onImageLoad = useCallback(
|
|
(e: SyntheticEvent<HTMLImageElement>) => {
|
|
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 (
|
|
<ImageCropContext.Provider value={contextValue}>
|
|
{children}
|
|
</ImageCropContext.Provider>
|
|
);
|
|
};
|
|
|
|
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 (
|
|
<ReactCrop
|
|
className={cn('max-h-[277px] max-w-full', className)}
|
|
crop={crop}
|
|
onChange={handleChange}
|
|
onComplete={handleComplete}
|
|
style={{ ...shadcnStyle, ...style }}
|
|
{...reactCropProps}
|
|
>
|
|
{imgSrc && (
|
|
<img
|
|
alt='crop'
|
|
className='size-full'
|
|
height={400}
|
|
onLoad={onImageLoad}
|
|
ref={imgRef}
|
|
src={imgSrc}
|
|
width={400}
|
|
/>
|
|
)}
|
|
</ReactCrop>
|
|
);
|
|
};
|
|
|
|
export type ImageCropApplyProps = ComponentProps<'button'> & {
|
|
asChild?: boolean;
|
|
};
|
|
|
|
export const ImageCropApply = ({
|
|
asChild = false,
|
|
children,
|
|
onClick,
|
|
...props
|
|
}: ImageCropApplyProps) => {
|
|
const { applyCrop } = useImageCrop();
|
|
|
|
const handleClick = async (e: MouseEvent<HTMLButtonElement>) => {
|
|
await applyCrop();
|
|
onClick?.(e);
|
|
};
|
|
|
|
if (asChild) {
|
|
return (
|
|
<Slot.Root onClick={handleClick} {...(props as any)}>
|
|
{children}
|
|
</Slot.Root>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Button
|
|
onClick={handleClick}
|
|
size='icon'
|
|
variant='ghost'
|
|
{...(props as any)}
|
|
>
|
|
{children ?? <CropIcon className='size-4' />}
|
|
</Button>
|
|
);
|
|
};
|
|
|
|
export type ImageCropResetProps = ComponentProps<'button'> & {
|
|
asChild?: boolean;
|
|
};
|
|
|
|
export const ImageCropReset = ({
|
|
asChild = false,
|
|
children,
|
|
onClick,
|
|
...props
|
|
}: ImageCropResetProps) => {
|
|
const { resetCrop } = useImageCrop();
|
|
|
|
const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
|
|
resetCrop();
|
|
onClick?.(e);
|
|
};
|
|
|
|
if (asChild) {
|
|
return (
|
|
<Slot.Root onClick={handleClick} {...(props as any)}>
|
|
{children}
|
|
</Slot.Root>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Button
|
|
onClick={handleClick}
|
|
size='icon'
|
|
variant='ghost'
|
|
{...(props as any)}
|
|
>
|
|
{children ?? <RotateCcwIcon className='size-4' />}
|
|
</Button>
|
|
);
|
|
};
|
|
|
|
// Keep the original Cropper component for backward compatibility
|
|
export type CropperProps = Omit<ReactCropProps, 'onChange'> & {
|
|
file: File;
|
|
maxImageSize?: number;
|
|
onCrop?: (croppedImage: string) => void;
|
|
onChange?: ReactCropProps['onChange'];
|
|
};
|
|
|
|
export const Cropper = ({
|
|
onChange,
|
|
onComplete,
|
|
onCrop,
|
|
style,
|
|
className,
|
|
file,
|
|
maxImageSize,
|
|
...props
|
|
}: CropperProps) => (
|
|
<ImageCrop
|
|
file={file}
|
|
maxImageSize={maxImageSize}
|
|
onChange={onChange}
|
|
onComplete={onComplete}
|
|
onCrop={onCrop}
|
|
{...(props as any)}
|
|
>
|
|
<ImageCropContent className={className} style={style} />
|
|
</ImageCrop>
|
|
);
|
|
|
|
export const Demo = () => {
|
|
const [file, setFile] = useState<File | null>(null);
|
|
const [croppedImage, setCroppedImage] = useState<string | null>(null);
|
|
|
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const selectedFile = e.target.files?.[0];
|
|
if (selectedFile) {
|
|
setFile(selectedFile);
|
|
setCroppedImage(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className='fixed inset-0 flex items-center justify-center p-8'>
|
|
<div className='flex flex-col items-center gap-4'>
|
|
{!file ? (
|
|
<label className='border-muted-foreground/25 hover:border-muted-foreground/50 flex cursor-pointer flex-col items-center gap-2 rounded-lg border-2 border-dashed p-8 transition-colors'>
|
|
<UploadIcon className='text-muted-foreground size-8' />
|
|
<span className='text-muted-foreground text-sm'>
|
|
Click to upload an image
|
|
</span>
|
|
<input
|
|
type='file'
|
|
accept='image/*'
|
|
onChange={handleFileChange}
|
|
className='hidden'
|
|
/>
|
|
</label>
|
|
) : (
|
|
<div className='flex flex-col items-center gap-4'>
|
|
<ImageCrop file={file} aspect={1} onCrop={setCroppedImage}>
|
|
<ImageCropContent className='max-w-sm' />
|
|
<div className='mt-2 flex justify-center gap-2'>
|
|
<ImageCropReset />
|
|
<ImageCropApply />
|
|
</div>
|
|
</ImageCrop>
|
|
{croppedImage && (
|
|
<div className='flex flex-col items-center gap-2'>
|
|
<span className='text-muted-foreground text-sm'>
|
|
Cropped result:
|
|
</span>
|
|
<img
|
|
src={croppedImage}
|
|
alt='Cropped'
|
|
className='max-w-32 rounded-lg'
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|