update packages
This commit is contained in:
435
packages/ui/src/image-crop.tsx
Normal file
435
packages/ui/src/image-crop.tsx
Normal file
@@ -0,0 +1,435 @@
|
||||
'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 function 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user