diff --git a/frontend/src/shared/components/CameraCapture/CropTool.tsx b/frontend/src/shared/components/CameraCapture/CropTool.tsx index 09f8b54..b1fd655 100644 --- a/frontend/src/shared/components/CameraCapture/CropTool.tsx +++ b/frontend/src/shared/components/CameraCapture/CropTool.tsx @@ -25,11 +25,13 @@ export const CropTool: React.FC = ({ const imageAreaRef = useRef(null); const [imageMaxHeight, setImageMaxHeight] = useState(0); - const { cropArea, isDragging, resetCrop, executeCrop, handleDragStart } = + const { cropArea, cropDrawn, isDragging, resetCrop, executeCrop, handleDragStart, handleDrawStart } = useImageCrop({ aspectRatio: lockAspectRatio ? aspectRatio : undefined, }); + const showCropArea = cropDrawn || (isDragging && cropArea.width > 1 && cropArea.height > 1); + // Measure available height for the image so the crop container // matches the rendered image exactly (fixes mobile crop offset) useEffect(() => { @@ -108,126 +110,150 @@ export const CropTool: React.FC = ({ draggable={false} /> + {/* Draw surface for free-form rectangle drawing */} + {!cropDrawn && ( + + )} + {/* Dark overlay outside crop area */} - - {/* Top overlay */} - - {/* Bottom overlay */} - - {/* Left overlay */} - - {/* Right overlay */} - - - - {/* Crop area with handles */} - - {/* Move handle (center area) */} - - - {/* Corner handles */} - - - - - - {/* Edge handles */} - - - - - - {/* Grid lines for alignment */} + {showCropArea && ( - {Array.from({ length: 9 }).map((_, i) => ( - - ))} + {/* Top overlay */} + + {/* Bottom overlay */} + + {/* Left overlay */} + + {/* Right overlay */} + - + )} + + {/* Crop area border and handles */} + {showCropArea && ( + + {/* Handles only appear after drawing is complete */} + {cropDrawn && ( + <> + {/* Move handle (center area) */} + + + {/* Corner handles */} + + + + + + {/* Edge handles */} + + + + + + )} + + {/* Grid lines for alignment */} + + {Array.from({ length: 9 }).map((_, i) => ( + + ))} + + + )} {/* Instructions */} - Drag to adjust crop area + {cropDrawn ? 'Drag handles to adjust crop area' : 'Tap and drag to select crop area'} @@ -255,7 +281,7 @@ export const CropTool: React.FC = ({ onClick={handleReset} startIcon={} sx={{ color: 'white' }} - disabled={isProcessing} + disabled={isProcessing || !cropDrawn} > Reset @@ -271,7 +297,7 @@ export const CropTool: React.FC = ({ void; - /** Reset crop to initial/default */ + /** Reset crop to drawing mode */ resetCrop: () => void; /** Execute crop and return cropped blob */ executeCrop: (imageSrc: string, mimeType?: string) => Promise; /** Handle drag start for crop handles */ handleDragStart: (handle: CropHandle, event: React.MouseEvent | React.TouchEvent) => void; + /** Handle draw start for free-form rectangle drawing */ + handleDrawStart: (event: React.MouseEvent | React.TouchEvent) => void; /** Handle move during drag */ handleMove: (event: MouseEvent | TouchEvent) => void; /** Handle drag end */ @@ -78,12 +82,22 @@ export function useImageCrop(options: UseImageCropOptions = {}): UseImageCropRet const [cropArea, setCropAreaState] = useState( getAspectRatioAdjustedCrop(initialCrop) ); + const [cropDrawn, setCropDrawn] = useState(false); const [isDragging, setIsDragging] = useState(false); const activeHandleRef = useRef(null); const startPosRef = useRef({ x: 0, y: 0 }); const startCropRef = useRef(cropArea); - const containerRef = useRef<{ width: number; height: number }>({ width: 100, height: 100 }); + const containerRef = useRef<{ width: number; height: number; left: number; top: number }>({ + width: 100, height: 100, left: 0, top: 0, + }); + const isDrawingRef = useRef(false); + const drawOriginRef = useRef({ x: 0, y: 0 }); + const cropAreaRef = useRef(cropArea); + + useEffect(() => { + cropAreaRef.current = cropArea; + }, [cropArea]); const setCropArea = useCallback( (area: CropArea) => { @@ -94,6 +108,7 @@ export function useImageCrop(options: UseImageCropOptions = {}): UseImageCropRet const resetCrop = useCallback(() => { setCropAreaState(getAspectRatioAdjustedCrop(initialCrop)); + setCropDrawn(false); }, [initialCrop, getAspectRatioAdjustedCrop]); const constrainCrop = useCallback( @@ -136,19 +151,75 @@ export function useImageCrop(options: UseImageCropOptions = {}): UseImageCropRet const container = target.closest('[data-crop-container]'); if (container) { const rect = container.getBoundingClientRect(); - containerRef.current = { width: rect.width, height: rect.height }; + containerRef.current = { width: rect.width, height: rect.height, left: rect.left, top: rect.top }; } }, [cropArea] ); - const handleMove = useCallback( - (event: MouseEvent | TouchEvent) => { - if (!activeHandleRef.current || !isDragging) return; + const handleDrawStart = useCallback( + (event: React.MouseEvent | React.TouchEvent) => { + event.preventDefault(); + + const target = event.currentTarget as HTMLElement; + const container = target.closest('[data-crop-container]'); + if (!container) return; + + const rect = container.getBoundingClientRect(); + containerRef.current = { width: rect.width, height: rect.height, left: rect.left, top: rect.top }; const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX; const clientY = 'touches' in event ? event.touches[0].clientY : event.clientY; + const x = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100)); + const y = Math.max(0, Math.min(100, ((clientY - rect.top) / rect.height) * 100)); + + startPosRef.current = { x: clientX, y: clientY }; + drawOriginRef.current = { x, y }; + + setCropAreaState({ x, y, width: 0, height: 0 }); + + isDrawingRef.current = true; + activeHandleRef.current = null; + setIsDragging(true); + }, + [] + ); + + const handleMove = useCallback( + (event: MouseEvent | TouchEvent) => { + if (!isDragging) return; + + const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX; + const clientY = 'touches' in event ? event.touches[0].clientY : event.clientY; + + // Free-form drawing mode: compute rectangle from origin to current pointer + if (isDrawingRef.current) { + const currentX = Math.max(0, Math.min(100, + ((clientX - containerRef.current.left) / containerRef.current.width) * 100)); + const currentY = Math.max(0, Math.min(100, + ((clientY - containerRef.current.top) / containerRef.current.height) * 100)); + + const originX = drawOriginRef.current.x; + const originY = drawOriginRef.current.y; + + let newCrop: CropArea = { + x: Math.min(originX, currentX), + y: Math.min(originY, currentY), + width: Math.abs(currentX - originX), + height: Math.abs(currentY - originY), + }; + + if (aspectRatio) { + newCrop.height = newCrop.width / aspectRatio; + } + + setCropAreaState(newCrop); + return; + } + + if (!activeHandleRef.current) return; + // Calculate delta as percentage of container const deltaX = ((clientX - startPosRef.current.x) / containerRef.current.width) * 100; const deltaY = ((clientY - startPosRef.current.y) / containerRef.current.height) * 100; @@ -234,13 +305,20 @@ export function useImageCrop(options: UseImageCropOptions = {}): UseImageCropRet setCropAreaState(constrainCrop(newCrop)); }, - [isDragging, constrainCrop] + [isDragging, constrainCrop, aspectRatio] ); const handleDragEnd = useCallback(() => { + if (isDrawingRef.current) { + isDrawingRef.current = false; + const area = cropAreaRef.current; + if (area.width >= minSize && area.height >= minSize) { + setCropDrawn(true); + } + } activeHandleRef.current = null; setIsDragging(false); - }, []); + }, [minSize]); // Add global event listeners for drag useEffect(() => { @@ -320,11 +398,13 @@ export function useImageCrop(options: UseImageCropOptions = {}): UseImageCropRet return { cropArea, + cropDrawn, isDragging, setCropArea, resetCrop, executeCrop, handleDragStart, + handleDrawStart, handleMove, handleDragEnd, };