This commit is contained in:
ryan
2026-07-13 15:17:45 +08:00
parent 08e61ce833
commit e0eb4e5975
334 changed files with 17212 additions and 12321 deletions
+171 -129
View File
@@ -1,7 +1,7 @@
"use client"
'use client';
import {CropIcon, RotateCcwIcon} from "lucide-react"
import {Slot} from "@radix-ui/react-slot"
import { CropIcon, RotateCcwIcon } from 'lucide-react';
import { Slot } from '@radix-ui/react-slot';
import {
type ComponentProps,
createContext,
@@ -15,18 +15,18 @@ import {
useEffect,
useRef,
useState,
} from "react"
} from 'react';
import ReactCrop, {
centerCrop,
makeAspectCrop,
type PercentCrop,
type PixelCrop,
type ReactCropProps,
} from "react-image-crop"
import {Button} from "@/components/ui/button"
import {cn} from "@/lib/utils"
} from 'react-image-crop';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import "react-image-crop/dist/ReactCrop.css"
import 'react-image-crop/dist/ReactCrop.css';
const centerAspectCrop = (
mediaWidth: number,
@@ -37,17 +37,17 @@ const centerAspectCrop = (
aspect
? makeAspectCrop(
{
unit: "%",
unit: '%',
width: 90,
},
aspect,
mediaWidth,
mediaHeight,
)
: { x: 0, y: 0, width: 90, height: 90, unit: "%" },
: { x: 0, y: 0, width: 90, height: 90, unit: '%' },
mediaWidth,
mediaHeight,
)
);
const getCroppedPngImage = async (
imageSrc: HTMLImageElement,
@@ -55,19 +55,19 @@ const getCroppedPngImage = async (
pixelCrop: PixelCrop,
maxImageSize: number,
): Promise<string> => {
const canvas = document.createElement("canvas")
const ctx = canvas.getContext("2d")
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error("Context is null, this should never happen.")
throw new Error('Context is null, this should never happen.');
}
const scaleX = imageSrc.naturalWidth / imageSrc.width
const scaleY = imageSrc.naturalHeight / imageSrc.height
const scaleX = imageSrc.naturalWidth / imageSrc.width;
const scaleY = imageSrc.naturalHeight / imageSrc.height;
ctx.imageSmoothingEnabled = false
canvas.width = pixelCrop.width
canvas.height = pixelCrop.height
ctx.imageSmoothingEnabled = false;
canvas.width = pixelCrop.width;
canvas.height = pixelCrop.height;
ctx.drawImage(
imageSrc,
@@ -79,53 +79,61 @@ const getCroppedPngImage = async (
0,
canvas.width,
canvas.height,
)
);
const croppedImageUrl = canvas.toDataURL("image/png")
const response = await fetch(croppedImageUrl)
const blob = await response.blob()
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 await getCroppedPngImage(
imageSrc,
scaleFactor * 0.9,
pixelCrop,
maxImageSize,
);
}
return croppedImageUrl
}
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
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 ImageCropContext = createContext<ImageCropContextType | null>(null);
const useImageCrop = () => {
const context = useContext(ImageCropContext)
const context = useContext(ImageCropContext);
if (!context) {
throw new Error("ImageCrop components must be used within ImageCrop")
throw new Error('ImageCrop components must be used within ImageCrop');
}
return context
}
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">
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,
@@ -136,54 +144,64 @@ export const ImageCrop = ({
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>()
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 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)
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)
}
setCrop(percentCrop);
onChange?.(pixelCrop, percentCrop);
};
const handleComplete = async (pixelCrop: PixelCrop, percentCrop: PercentCrop) => {
setCompletedCrop(pixelCrop)
onComplete?.(pixelCrop, percentCrop)
}
const handleComplete = async (
pixelCrop: PixelCrop,
percentCrop: PercentCrop,
) => {
setCompletedCrop(pixelCrop);
onComplete?.(pixelCrop, percentCrop);
};
const applyCrop = async () => {
if (!(imgRef.current && completedCrop)) {
return
return;
}
const croppedImage = await getCroppedPngImage(imgRef.current, 1, completedCrop, maxImageSize)
const croppedImage = await getCroppedPngImage(
imgRef.current,
1,
completedCrop,
maxImageSize,
);
onCrop?.(croppedImage)
}
onCrop?.(croppedImage);
};
const resetCrop = () => {
if (initialCrop) {
setCrop(initialCrop)
setCompletedCrop(null)
setCrop(initialCrop);
setCompletedCrop(null);
}
}
};
const contextValue: ImageCropContextType = {
file,
@@ -199,28 +217,42 @@ export const ImageCrop = ({
onImageLoad,
applyCrop,
resetCrop,
}
};
return <ImageCropContext.Provider value={contextValue}>{children}</ImageCropContext.Provider>
}
return (
<ImageCropContext.Provider value={contextValue}>
{children}
</ImageCropContext.Provider>
);
};
export interface ImageCropContentProps {
style?: CSSProperties
className?: string
style?: CSSProperties;
className?: string;
}
export const ImageCropContent = ({ style, className }: ImageCropContentProps) => {
const { imgSrc, crop, handleChange, handleComplete, onImageLoad, imgRef, reactCropProps } =
useImageCrop()
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
'--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)}
className={cn('max-h-[277px] max-w-full', className)}
crop={crop}
onChange={handleChange}
onComplete={handleComplete}
@@ -230,8 +262,8 @@ export const ImageCropContent = ({ style, className }: ImageCropContentProps) =>
{imgSrc && (
// eslint-disable-next-line @next/next/no-img-element
<img
alt="crop"
className="size-full"
alt='crop'
className='size-full'
height={400}
onLoad={onImageLoad}
ref={imgRef}
@@ -240,12 +272,12 @@ export const ImageCropContent = ({ style, className }: ImageCropContentProps) =>
/>
)}
</ReactCrop>
)
}
);
};
export type ImageCropApplyProps = ComponentProps<"button"> & {
asChild?: boolean
}
export type ImageCropApplyProps = ComponentProps<'button'> & {
asChild?: boolean;
};
export const ImageCropApply = ({
asChild = false,
@@ -253,31 +285,36 @@ export const ImageCropApply = ({
onClick,
...props
}: ImageCropApplyProps) => {
const { applyCrop } = useImageCrop()
const { applyCrop } = useImageCrop();
const handleClick = async (e: MouseEvent<HTMLButtonElement>) => {
await applyCrop()
onClick?.(e)
}
await applyCrop();
onClick?.(e);
};
if (asChild) {
return (
<Slot onClick={handleClick} {...(props as ComponentProps<"button">)}>
<Slot onClick={handleClick} {...(props as ComponentProps<'button'>)}>
{children}
</Slot>
)
);
}
return (
<Button onClick={handleClick} size="icon" variant="ghost" {...(props as ComponentProps<"button">)}>
{children ?? <CropIcon className="size-4" />}
<Button
onClick={handleClick}
size='icon'
variant='ghost'
{...(props as ComponentProps<'button'>)}
>
{children ?? <CropIcon className='size-4' />}
</Button>
)
}
);
};
export type ImageCropResetProps = ComponentProps<"button"> & {
asChild?: boolean
}
export type ImageCropResetProps = ComponentProps<'button'> & {
asChild?: boolean;
};
export const ImageCropReset = ({
asChild = false,
@@ -285,35 +322,40 @@ export const ImageCropReset = ({
onClick,
...props
}: ImageCropResetProps) => {
const { resetCrop } = useImageCrop()
const { resetCrop } = useImageCrop();
const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
resetCrop()
onClick?.(e)
}
resetCrop();
onClick?.(e);
};
if (asChild) {
return (
<Slot onClick={handleClick} {...(props as ComponentProps<"button">)}>
<Slot onClick={handleClick} {...(props as ComponentProps<'button'>)}>
{children}
</Slot>
)
);
}
return (
<Button onClick={handleClick} size="icon" variant="ghost" {...(props as ComponentProps<"button">)}>
{children ?? <RotateCcwIcon className="size-4" />}
<Button
onClick={handleClick}
size='icon'
variant='ghost'
{...(props as ComponentProps<'button'>)}
>
{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 type CropperProps = Omit<ReactCropProps, 'onChange'> & {
file: File;
maxImageSize?: number;
onCrop?: (croppedImage: string) => void;
onChange?: ReactCropProps['onChange'];
};
export const Cropper = ({
onChange,
@@ -331,8 +373,8 @@ export const Cropper = ({
onChange={onChange}
onComplete={onComplete}
onCrop={onCrop}
{...(props as Omit<ReactCropProps, "onChange" | "onComplete" | "children">)}
{...(props as Omit<ReactCropProps, 'onChange' | 'onComplete' | 'children'>)}
>
<ImageCropContent className={className} style={style} />
</ImageCrop>
)
);