From efc55cd3dbb2c4b9850c1213d69a03df1bbbf273 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sat, 7 Feb 2026 20:05:40 -0600 Subject: [PATCH] feat: improve VIN camera crop overlay-to-crop alignment and touch targets (refs #123) Bridge guidance overlay position to crop tool initial coordinates so the crop box appears centered matching the viewfinder guide. Increase handle touch targets to 44px (32px on compact viewports) for mobile usability. Co-Authored-By: Claude Opus 4.6 --- .../CameraCapture/CameraCapture.tsx | 5 ++ .../components/CameraCapture/CropTool.tsx | 38 +++++++------ .../getInitialCropForGuidance.test.ts | 53 +++++++++++++++++++ .../shared/components/CameraCapture/types.ts | 33 ++++++++++++ .../components/CameraCapture/useImageCrop.ts | 3 +- 5 files changed, 116 insertions(+), 16 deletions(-) create mode 100644 frontend/src/shared/components/CameraCapture/__tests__/getInitialCropForGuidance.test.ts 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);