feat: Add image cropping functionality with a new component, dialog, and canvas utilities.

This commit is contained in:
syntaxbullet
2026-02-06 12:45:09 +01:00
parent 34958aa220
commit 1ffe397fbb
7 changed files with 365 additions and 9 deletions

View File

@@ -26,6 +26,7 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19", "react": "^19",
"react-dom": "^19", "react-dom": "^19",
"react-easy-crop": "^5.5.6",
"react-hook-form": "^7.70.0", "react-hook-form": "^7.70.0",
"react-router-dom": "^7.12.0", "react-router-dom": "^7.12.0",
"recharts": "^3.6.0", "recharts": "^3.6.0",
@@ -245,10 +246,14 @@
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
"normalize-wheel": ["normalize-wheel@1.0.1", "", {}, "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA=="],
"react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
"react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
"react-easy-crop": ["react-easy-crop@5.5.6", "", { "dependencies": { "normalize-wheel": "^1.0.1", "tslib": "^2.0.1" }, "peerDependencies": { "react": ">=16.4.0", "react-dom": ">=16.4.0" } }, "sha512-Jw3/ozs8uXj3NpL511Suc4AHY+mLRO23rUgipXvNYKqezcFSYHxe4QXibBymkOoY6oOtLVMPO2HNPRHYvMPyTw=="],
"react-hook-form": ["react-hook-form@7.70.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-COOMajS4FI3Wuwrs3GPpi/Jeef/5W1DRR84Yl5/ShlT3dKVFUfoGiEZ/QE6Uw8P4T2/CLJdcTVYKvWBMQTEpvw=="], "react-hook-form": ["react-hook-form@7.70.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-COOMajS4FI3Wuwrs3GPpi/Jeef/5W1DRR84Yl5/ShlT3dKVFUfoGiEZ/QE6Uw8P4T2/CLJdcTVYKvWBMQTEpvw=="],
"react-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="], "react-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="],

View File

@@ -30,6 +30,7 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19", "react": "^19",
"react-dom": "^19", "react-dom": "^19",
"react-easy-crop": "^5.5.6",
"react-hook-form": "^7.70.0", "react-hook-form": "^7.70.0",
"react-router-dom": "^7.12.0", "react-router-dom": "^7.12.0",
"recharts": "^3.6.0", "recharts": "^3.6.0",

View File

@@ -0,0 +1,114 @@
import { useState, useCallback } from "react";
import Cropper, { type Area } from "react-easy-crop";
import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { getCroppedImg } from "@/lib/canvasUtils";
import { Loader2 } from "lucide-react";
interface ImageCropperProps {
imageSrc: string | null;
isOpen: boolean;
onClose: () => void;
onCropComplete: (croppedImage: Blob) => void;
}
export function ImageCropper({
imageSrc,
isOpen,
onClose,
onCropComplete,
}: ImageCropperProps) {
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
const [isLoading, setIsLoading] = useState(false);
const onCropChange = (crop: { x: number; y: number }) => {
setCrop(crop);
};
const onZoomChange = (zoom: number) => {
setZoom(zoom);
};
const onCropCompleteHandler = useCallback(
(_croppedArea: Area, croppedAreaPixels: Area) => {
setCroppedAreaPixels(croppedAreaPixels);
},
[]
);
const handleSave = async () => {
if (!imageSrc || !croppedAreaPixels) return;
setIsLoading(true);
try {
const croppedImage = await getCroppedImg(imageSrc, croppedAreaPixels);
if (croppedImage) {
onCropComplete(croppedImage);
onClose();
}
} catch (e) {
console.error(e);
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Crop Image</DialogTitle>
</DialogHeader>
<div className="relative w-full h-80 bg-black/5 rounded-md overflow-hidden mt-4">
{imageSrc && (
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={1}
onCropChange={onCropChange}
onCropComplete={onCropCompleteHandler}
onZoomChange={onZoomChange}
/>
)}
</div>
<div className="py-4 space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Zoom</span>
<span className="text-muted-foreground">{zoom.toFixed(1)}x</span>
</div>
<Slider
value={[zoom]}
min={1}
max={3}
step={0.1}
onValueChange={(value) => onZoomChange(value[0] ?? 1)}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={isLoading}>
Cancel
</Button>
<Button onClick={handleSave} disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Crop & Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default ImageCropper;

View File

@@ -7,6 +7,7 @@ import { useState, useCallback, useRef } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Upload, X, Image as ImageIcon, Loader2, AlertCircle } from "lucide-react"; import { Upload, X, Image as ImageIcon, Loader2, AlertCircle } from "lucide-react";
import { ImageCropper } from "@/components/image-cropper";
interface ImageUploaderProps { interface ImageUploaderProps {
existingUrl?: string | null; existingUrl?: string | null;
@@ -28,6 +29,12 @@ export function ImageUploader({
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
// Cropper state
const [isCropperOpen, setIsCropperOpen] = useState(false);
const [tempImageSrc, setTempImageSrc] = useState<string | null>(null);
const [tempFileName, setTempFileName] = useState<string>("image.jpg");
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const validateFile = useCallback((file: File): string | null => { const validateFile = useCallback((file: File): string | null => {
@@ -50,11 +57,13 @@ export function ImageUploader({
} }
setIsLoading(true); setIsLoading(true);
setTempFileName(file.name);
// Create preview URL // Read file for cropper
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
setPreviewUrl(reader.result as string); setTempImageSrc(reader.result as string);
setIsCropperOpen(true);
setIsLoading(false); setIsLoading(false);
}; };
reader.onerror = () => { reader.onerror = () => {
@@ -62,9 +71,21 @@ export function ImageUploader({
setIsLoading(false); setIsLoading(false);
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
}, [validateFile]);
const handleCropComplete = useCallback((croppedBlob: Blob) => {
// Convert blob back to file
const file = new File([croppedBlob], tempFileName, { type: "image/jpeg" });
// Create preview
const url = URL.createObjectURL(croppedBlob);
setPreviewUrl(url);
// Pass to parent
onFileSelect(file); onFileSelect(file);
}, [onFileSelect, validateFile]); setIsCropperOpen(false);
setTempImageSrc(null);
}, [onFileSelect, tempFileName]);
const handleDragEnter = useCallback((e: React.DragEvent) => { const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
@@ -217,12 +238,20 @@ export function ImageUploader({
/> />
</div> </div>
{error && ( {error && (
<p className="text-sm text-destructive flex items-center gap-1"> <p className="text-sm text-destructive flex items-center gap-1">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
{error} {error}
</p> </p>
)} )}
<ImageCropper
isOpen={isCropperOpen}
onClose={() => setIsCropperOpen(false)}
onCropComplete={handleCropComplete}
imageSrc={tempImageSrc}
/>
</div> </div>
); );
} }

View File

@@ -133,8 +133,7 @@ export function LootTableBuilder({ pool, onChange }: LootTableBuilderProps) {
{/* Drop List */} {/* Drop List */}
<div className="space-y-2"> <div className="space-y-2">
{pool.map((drop, index) => { {pool.map((drop, index) => {
const lootType = LOOT_TYPES.find(t => t.value === drop.type);
const Icon = lootType?.icon || Package;
return ( return (
<Card key={index} className="bg-muted/20 border-border/30"> <Card key={index} className="bg-muted/20 border-border/30">
@@ -149,10 +148,7 @@ export function LootTableBuilder({ pool, onChange }: LootTableBuilderProps) {
onValueChange={(value) => changeDropType(index, value as LootType)} onValueChange={(value) => changeDropType(index, value as LootType)}
> >
<SelectTrigger className="w-32 bg-background/50"> <SelectTrigger className="w-32 bg-background/50">
<div className="flex items-center gap-2"> <SelectValue />
<Icon className={cn("h-4 w-4", lootType?.color)} />
<SelectValue />
</div>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{LOOT_TYPES.map((type) => ( {LOOT_TYPES.map((type) => (

View File

@@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,89 @@
export const createImage = (url: string): Promise<HTMLImageElement> =>
new Promise((resolve, reject) => {
const image = new Image()
image.addEventListener('load', () => resolve(image))
image.addEventListener('error', (error) => reject(error))
image.setAttribute('crossOrigin', 'anonymous') // needed to avoid cross-origin issues on CodeSandbox
image.src = url
})
export function getRadianAngle(degreeValue: number) {
return (degreeValue * Math.PI) / 180
}
/**
* Returns the new bounding area of a rotated rectangle.
*/
export function rotateSize(width: number, height: number, rotation: number) {
const rotRad = getRadianAngle(rotation)
return {
width:
Math.abs(Math.cos(rotRad) * width) + Math.abs(Math.sin(rotRad) * height),
height:
Math.abs(Math.sin(rotRad) * width) + Math.abs(Math.cos(rotRad) * height),
}
}
/**
* This function was adapted from the one in the ReadMe of https://github.com/DominicTobias/react-image-crop
*/
export async function getCroppedImg(
imageSrc: string,
pixelCrop: { x: number; y: number; width: number; height: number },
rotation = 0,
flip = { horizontal: false, vertical: false }
): Promise<Blob | null> {
const image = await createImage(imageSrc)
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) {
return null
}
const rotRad = getRadianAngle(rotation)
// calculate bounding box of the rotated image
const { width: bBoxWidth, height: bBoxHeight } = rotateSize(
image.width,
image.height,
rotation
)
// set canvas size to match the bounding box
canvas.width = bBoxWidth
canvas.height = bBoxHeight
// translate canvas context to a central location to allow rotating and flipping around the center
ctx.translate(bBoxWidth / 2, bBoxHeight / 2)
ctx.rotate(rotRad)
ctx.scale(flip.horizontal ? -1 : 1, flip.vertical ? -1 : 1)
ctx.translate(-image.width / 2, -image.height / 2)
// draw rotated image
ctx.drawImage(image, 0, 0)
// croppedAreaPixels values are bounding box relative
// extract the cropped image using these values
const data = ctx.getImageData(
pixelCrop.x,
pixelCrop.y,
pixelCrop.width,
pixelCrop.height
)
// set canvas width to final desired crop size - this will clear existing context
canvas.width = pixelCrop.width
canvas.height = pixelCrop.height
// paste generated rotate image at the top left corner
ctx.putImageData(data, 0, 0)
// As a Blob
return new Promise((resolve) => {
canvas.toBlob((file) => {
resolve(file)
}, 'image/jpeg')
})
}