From 1ffe397fbbffe0d997b9d75f2605d2899347eb81 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 6 Feb 2026 12:45:09 +0100 Subject: [PATCH] feat: Add image cropping functionality with a new component, dialog, and canvas utilities. --- web/bun.lock | 5 + web/package.json | 1 + web/src/components/image-cropper.tsx | 114 ++++++++++++++++++++ web/src/components/image-uploader.tsx | 35 ++++++- web/src/components/loot-table-builder.tsx | 8 +- web/src/components/ui/dialog.tsx | 122 ++++++++++++++++++++++ web/src/lib/canvasUtils.ts | 89 ++++++++++++++++ 7 files changed, 365 insertions(+), 9 deletions(-) create mode 100644 web/src/components/image-cropper.tsx create mode 100644 web/src/components/ui/dialog.tsx create mode 100644 web/src/lib/canvasUtils.ts diff --git a/web/bun.lock b/web/bun.lock index 12639c9..d06217b 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -26,6 +26,7 @@ "next-themes": "^0.4.6", "react": "^19", "react-dom": "^19", + "react-easy-crop": "^5.5.6", "react-hook-form": "^7.70.0", "react-router-dom": "^7.12.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=="], + "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-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-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="], diff --git a/web/package.json b/web/package.json index 799d50c..1d10c8c 100644 --- a/web/package.json +++ b/web/package.json @@ -30,6 +30,7 @@ "next-themes": "^0.4.6", "react": "^19", "react-dom": "^19", + "react-easy-crop": "^5.5.6", "react-hook-form": "^7.70.0", "react-router-dom": "^7.12.0", "recharts": "^3.6.0", diff --git a/web/src/components/image-cropper.tsx b/web/src/components/image-cropper.tsx new file mode 100644 index 0000000..50bb0df --- /dev/null +++ b/web/src/components/image-cropper.tsx @@ -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(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 ( + !open && onClose()}> + + + Crop Image + + +
+ {imageSrc && ( + + )} +
+ +
+
+ Zoom + {zoom.toFixed(1)}x +
+ onZoomChange(value[0] ?? 1)} + /> +
+ + + + + +
+
+ ); +} + +export default ImageCropper; diff --git a/web/src/components/image-uploader.tsx b/web/src/components/image-uploader.tsx index 080ef9d..6adf2ee 100644 --- a/web/src/components/image-uploader.tsx +++ b/web/src/components/image-uploader.tsx @@ -7,6 +7,7 @@ import { useState, useCallback, useRef } from "react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Upload, X, Image as ImageIcon, Loader2, AlertCircle } from "lucide-react"; +import { ImageCropper } from "@/components/image-cropper"; interface ImageUploaderProps { existingUrl?: string | null; @@ -28,6 +29,12 @@ export function ImageUploader({ const [isDragging, setIsDragging] = useState(false); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); + + // Cropper state + const [isCropperOpen, setIsCropperOpen] = useState(false); + const [tempImageSrc, setTempImageSrc] = useState(null); + const [tempFileName, setTempFileName] = useState("image.jpg"); + const inputRef = useRef(null); const validateFile = useCallback((file: File): string | null => { @@ -50,11 +57,13 @@ export function ImageUploader({ } setIsLoading(true); + setTempFileName(file.name); - // Create preview URL + // Read file for cropper const reader = new FileReader(); reader.onload = () => { - setPreviewUrl(reader.result as string); + setTempImageSrc(reader.result as string); + setIsCropperOpen(true); setIsLoading(false); }; reader.onerror = () => { @@ -62,9 +71,21 @@ export function ImageUploader({ setIsLoading(false); }; 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, validateFile]); + setIsCropperOpen(false); + setTempImageSrc(null); + }, [onFileSelect, tempFileName]); const handleDragEnter = useCallback((e: React.DragEvent) => { e.preventDefault(); @@ -217,12 +238,20 @@ export function ImageUploader({ /> + {error && (

{error}

)} + + setIsCropperOpen(false)} + onCropComplete={handleCropComplete} + imageSrc={tempImageSrc} + /> ); } diff --git a/web/src/components/loot-table-builder.tsx b/web/src/components/loot-table-builder.tsx index d69a7b0..7d74c0a 100644 --- a/web/src/components/loot-table-builder.tsx +++ b/web/src/components/loot-table-builder.tsx @@ -133,8 +133,7 @@ export function LootTableBuilder({ pool, onChange }: LootTableBuilderProps) { {/* Drop List */}
{pool.map((drop, index) => { - const lootType = LOOT_TYPES.find(t => t.value === drop.type); - const Icon = lootType?.icon || Package; + return ( @@ -149,10 +148,7 @@ export function LootTableBuilder({ pool, onChange }: LootTableBuilderProps) { onValueChange={(value) => changeDropType(index, value as LootType)} > -
- - -
+
{LOOT_TYPES.map((type) => ( diff --git a/web/src/components/ui/dialog.tsx b/web/src/components/ui/dialog.tsx new file mode 100644 index 0000000..0edcceb --- /dev/null +++ b/web/src/components/ui/dialog.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/web/src/lib/canvasUtils.ts b/web/src/lib/canvasUtils.ts new file mode 100644 index 0000000..135f99b --- /dev/null +++ b/web/src/lib/canvasUtils.ts @@ -0,0 +1,89 @@ +export const createImage = (url: string): Promise => + 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 { + 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') + }) +}