update packages

This commit is contained in:
2026-03-20 13:47:53 -05:00
parent a11af16346
commit d2eea9880a
68 changed files with 6070 additions and 655 deletions

View 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>
);
}