From 1a076be9b2cc5f434296f2739a395321368c2177 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 23 Mar 2026 19:18:03 +0800 Subject: [PATCH] feat(crop): add CropCanvas component with visual overlay, grid, and keyboard controls --- apps/web/src/components/tools/crop-canvas.tsx | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 apps/web/src/components/tools/crop-canvas.tsx diff --git a/apps/web/src/components/tools/crop-canvas.tsx b/apps/web/src/components/tools/crop-canvas.tsx new file mode 100644 index 00000000..77102453 --- /dev/null +++ b/apps/web/src/components/tools/crop-canvas.tsx @@ -0,0 +1,141 @@ +import { useRef, useCallback, useEffect } from "react"; +import ReactCrop, { type Crop } from "react-image-crop"; +import "react-image-crop/dist/ReactCrop.css"; + +export interface CropCanvasProps { + imageSrc: string; + crop: Crop; + aspect: number | undefined; + showGrid: boolean; + imgDimensions: { width: number; height: number } | null; + onCropChange: (crop: Crop) => void; + onImageLoad: (dims: { width: number; height: number }) => void; +} + +export function CropCanvas({ + imageSrc, + crop, + aspect, + showGrid, + imgDimensions, + onCropChange, + onImageLoad, +}: CropCanvasProps) { + const containerRef = useRef(null); + const imgRef = useRef(null); + + const handleImageLoad = useCallback( + (e: React.SyntheticEvent) => { + const img = e.currentTarget; + onImageLoad({ width: img.naturalWidth, height: img.naturalHeight }); + }, + [onImageLoad], + ); + + // Keyboard nudging + useEffect(() => { + const el = containerRef.current; + if (!el) return; + + const handleKeyDown = (e: KeyboardEvent) => { + const step = e.shiftKey ? 10 : 1; + const { naturalWidth, naturalHeight } = imgRef.current ?? { + naturalWidth: 0, + naturalHeight: 0, + }; + if (!naturalWidth || !naturalHeight) return; + + // Convert step from pixels to percentage + const stepX = (step / naturalWidth) * 100; + const stepY = (step / naturalHeight) * 100; + + let dx = 0; + let dy = 0; + if (e.key === "ArrowLeft") dx = -stepX; + else if (e.key === "ArrowRight") dx = stepX; + else if (e.key === "ArrowUp") dy = -stepY; + else if (e.key === "ArrowDown") dy = stepY; + else if (e.key === "Escape") { + // Reset to full image + onCropChange({ + unit: "%", + x: 0, + y: 0, + width: 100, + height: 100, + }); + e.preventDefault(); + return; + } else if (e.key === "Enter") { + // Submit the crop form (find and submit closest form) + const form = document.querySelector( + 'form[data-crop-form]', + ); + if (form) form.requestSubmit(); + e.preventDefault(); + return; + } else return; + + e.preventDefault(); + onCropChange({ + ...crop, + x: Math.max(0, Math.min(100 - crop.width, crop.x + dx)), + y: Math.max(0, Math.min(100 - crop.height, crop.y + dy)), + }); + }; + + el.addEventListener("keydown", handleKeyDown); + return () => el.removeEventListener("keydown", handleKeyDown); + }, [crop, onCropChange]); + + // Auto-focus the container on mount + useEffect(() => { + containerRef.current?.focus(); + }, []); + + // Calculate pixel dimensions for the badge + const pixelWidth = + imgDimensions ? Math.round((crop.width / 100) * imgDimensions.width) : 0; + const pixelHeight = + imgDimensions ? Math.round((crop.height / 100) * imgDimensions.height) : 0; + + return ( +
+ {/* Crop area */} +
+ + Crop preview + +
+ + {/* Info bar */} +
+ + Crop region: {pixelWidth} x {pixelHeight} + + {imgDimensions && ( + + Original: {imgDimensions.width} x {imgDimensions.height} + + )} +
+
+ ); +}