diff --git a/frontend/src/shared/components/CameraCapture/CameraCapture.tsx b/frontend/src/shared/components/CameraCapture/CameraCapture.tsx index 2e17580..c6bd8e1 100644 --- a/frontend/src/shared/components/CameraCapture/CameraCapture.tsx +++ b/frontend/src/shared/components/CameraCapture/CameraCapture.tsx @@ -12,6 +12,7 @@ import { DEFAULT_ACCEPTED_FORMATS, DEFAULT_MAX_FILE_SIZE, GUIDANCE_CONFIGS, + getInitialCropForGuidance, } from './types'; import { useCameraPermission } from './useCameraPermission'; import { CameraViewfinder } from './CameraViewfinder'; @@ -192,6 +193,9 @@ export const CameraCapture: React.FC = ({ const cropAspectRatio = guidanceType !== 'none' ? GUIDANCE_CONFIGS[guidanceType].aspectRatio : undefined; + // Calculate crop coordinates centered on the guidance overlay area + const cropInitialArea = getInitialCropForGuidance(guidanceType); + // Render permission error state if (permissionState === 'denied' && !useFallback) { return ( @@ -245,6 +249,7 @@ export const CameraCapture: React.FC = ({ return ( = ({ imageSrc, lockAspectRatio = false, + initialCrop, aspectRatio, onConfirm, onReset, @@ -25,9 +26,14 @@ export const CropTool: React.FC = ({ const imageAreaRef = useRef(null); const [imageMaxHeight, setImageMaxHeight] = useState(0); + // Responsive handle sizing: 44px standard, 32px on compact viewports + const isCompactViewport = useMediaQuery('(max-height: 400px)'); + const handleConfig = { size: isCompactViewport ? 32 : 44, visual: isCompactViewport ? 12 : 16 }; + const { cropArea, cropDrawn, isDragging, resetCrop, executeCrop, handleDragStart, handleDrawStart } = useImageCrop({ aspectRatio: lockAspectRatio ? aspectRatio : undefined, + initialCrop, }); const showCropArea = cropDrawn || (isDragging && cropArea.width > 1 && cropArea.height > 1); @@ -203,22 +209,22 @@ export const CropTool: React.FC = ({ onDragStart={handleDragStart} sx={{ position: 'absolute', - inset: 8, + inset: handleConfig.size / 2, cursor: 'move', }} /> {/* Corner handles */} - - - - + + + + {/* Edge handles */} - - - - + + + + )} @@ -326,6 +332,7 @@ export const CropTool: React.FC = ({ interface CropHandleProps { handle: CropHandle; onDragStart: (handle: CropHandle, event: React.MouseEvent | React.TouchEvent) => void; + config: { size: number; visual: number }; position: | 'top-left' | 'top-right' @@ -337,9 +344,9 @@ interface CropHandleProps { | 'right'; } -const CropHandle: React.FC = ({ handle, onDragStart, position }) => { - const handleSize = 24; - const handleVisualSize = 12; +const CropHandle: React.FC = ({ handle, onDragStart, position, config }) => { + const handleSize = config.size; + const handleVisualSize = config.visual; const positionStyles: Record = { 'top-left': { @@ -407,8 +414,9 @@ const CropHandle: React.FC = ({ handle, onDragStart, position } > { + it('returns undefined for "none" guidance type', () => { + expect(getInitialCropForGuidance('none')).toBeUndefined(); + }); + + it('returns centered 85%-width crop with 6:1 AR for VIN', () => { + const crop = getInitialCropForGuidance('vin'); + expect(crop).toBeDefined(); + if (!crop) return; + expect(crop.width).toBe(85); + expect(crop.height).toBeCloseTo(85 / 6, 1); + expect(crop.x).toBeCloseTo((100 - 85) / 2, 1); + expect(crop.y).toBeCloseTo((100 - 85 / 6) / 2, 1); + }); + + it('returns centered 70%-height crop with 2:3 AR for receipt', () => { + const crop = getInitialCropForGuidance('receipt'); + expect(crop).toBeDefined(); + if (!crop) return; + const expectedWidth = 70 * (2 / 3); + expect(crop.height).toBe(70); + expect(crop.width).toBeCloseTo(expectedWidth, 1); + expect(crop.x).toBeCloseTo((100 - expectedWidth) / 2, 1); + expect(crop.y).toBeCloseTo((100 - 70) / 2, 1); + }); + + it('returns centered 70%-height crop with 8.5:11 AR for document', () => { + const crop = getInitialCropForGuidance('document'); + expect(crop).toBeDefined(); + if (!crop) return; + const expectedWidth = 70 * (8.5 / 11); + expect(crop.height).toBe(70); + expect(crop.width).toBeCloseTo(expectedWidth, 1); + expect(crop.x).toBeCloseTo((100 - expectedWidth) / 2, 1); + expect(crop.y).toBeCloseTo((100 - 70) / 2, 1); + }); + + it('returns crop areas within 0-100 bounds for all types', () => { + (['vin', 'receipt', 'document'] as GuidanceType[]).forEach((type) => { + const crop = getInitialCropForGuidance(type); + expect(crop).toBeDefined(); + if (!crop) return; + expect(crop.x).toBeGreaterThanOrEqual(0); + expect(crop.y).toBeGreaterThanOrEqual(0); + expect(crop.width).toBeGreaterThanOrEqual(0); + expect(crop.height).toBeGreaterThanOrEqual(0); + expect(crop.x + crop.width).toBeLessThanOrEqual(100); + expect(crop.y + crop.height).toBeLessThanOrEqual(100); + }); + }); +}); diff --git a/frontend/src/shared/components/CameraCapture/types.ts b/frontend/src/shared/components/CameraCapture/types.ts index 5909d2d..f60f6e8 100644 --- a/frontend/src/shared/components/CameraCapture/types.ts +++ b/frontend/src/shared/components/CameraCapture/types.ts @@ -61,6 +61,8 @@ export interface CropToolProps { imageSrc: string; /** Whether to lock aspect ratio */ lockAspectRatio?: boolean; + /** Initial crop area matching guidance overlay position */ + initialCrop?: CropArea; /** Aspect ratio to lock to (width/height) */ aspectRatio?: number; /** Callback when crop is confirmed */ @@ -129,3 +131,34 @@ export const DEFAULT_ACCEPTED_FORMATS = [ /** Default max file size (10MB) */ export const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; + +/** Clamps a value to the 0-100 percentage range */ +function clampPct(value: number): number { + return Math.max(0, Math.min(100, value)); +} + +/** Derives centered CropArea matching GuidanceOverlay position for each type */ +export function getInitialCropForGuidance(type: GuidanceType): CropArea | undefined { + if (type === 'none') return undefined; + + const config = GUIDANCE_CONFIGS[type]; + let width: number; + let height: number; + + if (config.aspectRatio >= 1) { + // Width-constrained (VIN, square): 85% width, height from AR, centered + width = 85; + height = width / config.aspectRatio; + } else { + // Height-constrained (receipt, document): 70% height, width from AR, centered + height = 70; + width = height * config.aspectRatio; + } + + return { + x: clampPct((100 - width) / 2), + y: clampPct((100 - height) / 2), + width: clampPct(width), + height: clampPct(height), + }; +} diff --git a/frontend/src/shared/components/CameraCapture/useImageCrop.ts b/frontend/src/shared/components/CameraCapture/useImageCrop.ts index c58cec5..351a5c4 100644 --- a/frontend/src/shared/components/CameraCapture/useImageCrop.ts +++ b/frontend/src/shared/components/CameraCapture/useImageCrop.ts @@ -82,7 +82,8 @@ export function useImageCrop(options: UseImageCropOptions = {}): UseImageCropRet const [cropArea, setCropAreaState] = useState( getAspectRatioAdjustedCrop(initialCrop) ); - const [cropDrawn, setCropDrawn] = useState(false); + // Pre-drawn when initialCrop is provided; user skips draw step + const [cropDrawn, setCropDrawn] = useState(!!options.initialCrop); const [isDragging, setIsDragging] = useState(false); const activeHandleRef = useRef(null);