feat: Improve VIN photo capture camera crop (#123) #124

Merged
egullickson merged 2 commits from issue-123-improve-vin-camera-crop into main 2026-02-09 00:36:44 +00:00
5 changed files with 116 additions and 16 deletions
Showing only changes of commit efc55cd3db - Show all commits

View File

@@ -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<CameraCaptureProps> = ({
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<CameraCaptureProps> = ({
return (
<CropTool
imageSrc={capturedImageSrc}
initialCrop={cropInitialArea}
lockAspectRatio={guidanceType !== 'none' && guidanceType !== 'vin'}
aspectRatio={cropAspectRatio}
onConfirm={handleCropConfirm}

View File

@@ -4,7 +4,7 @@
*/
import React, { useCallback, useState, useRef, useEffect } from 'react';
import { Box, IconButton, Button, Typography, CircularProgress } from '@mui/material';
import { Box, IconButton, Button, Typography, CircularProgress, useMediaQuery } from '@mui/material';
import CheckIcon from '@mui/icons-material/Check';
import RefreshIcon from '@mui/icons-material/Refresh';
import ReplayIcon from '@mui/icons-material/Replay';
@@ -15,6 +15,7 @@ import { useImageCrop, type CropHandle } from './useImageCrop';
export const CropTool: React.FC<CropToolProps> = ({
imageSrc,
lockAspectRatio = false,
initialCrop,
aspectRatio,
onConfirm,
onReset,
@@ -25,9 +26,14 @@ export const CropTool: React.FC<CropToolProps> = ({
const imageAreaRef = useRef<HTMLDivElement>(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<CropToolProps> = ({
onDragStart={handleDragStart}
sx={{
position: 'absolute',
inset: 8,
inset: handleConfig.size / 2,
cursor: 'move',
}}
/>
{/* Corner handles */}
<CropHandle handle="nw" onDragStart={handleDragStart} position="top-left" />
<CropHandle handle="ne" onDragStart={handleDragStart} position="top-right" />
<CropHandle handle="sw" onDragStart={handleDragStart} position="bottom-left" />
<CropHandle handle="se" onDragStart={handleDragStart} position="bottom-right" />
<CropHandle handle="nw" onDragStart={handleDragStart} position="top-left" config={handleConfig} />
<CropHandle handle="ne" onDragStart={handleDragStart} position="top-right" config={handleConfig} />
<CropHandle handle="sw" onDragStart={handleDragStart} position="bottom-left" config={handleConfig} />
<CropHandle handle="se" onDragStart={handleDragStart} position="bottom-right" config={handleConfig} />
{/* Edge handles */}
<CropHandle handle="n" onDragStart={handleDragStart} position="top" />
<CropHandle handle="s" onDragStart={handleDragStart} position="bottom" />
<CropHandle handle="w" onDragStart={handleDragStart} position="left" />
<CropHandle handle="e" onDragStart={handleDragStart} position="right" />
<CropHandle handle="n" onDragStart={handleDragStart} position="top" config={handleConfig} />
<CropHandle handle="s" onDragStart={handleDragStart} position="bottom" config={handleConfig} />
<CropHandle handle="w" onDragStart={handleDragStart} position="left" config={handleConfig} />
<CropHandle handle="e" onDragStart={handleDragStart} position="right" config={handleConfig} />
</>
)}
@@ -326,6 +332,7 @@ export const CropTool: React.FC<CropToolProps> = ({
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<CropHandleProps> = ({ handle, onDragStart, position }) => {
const handleSize = 24;
const handleVisualSize = 12;
const CropHandle: React.FC<CropHandleProps> = ({ handle, onDragStart, position, config }) => {
const handleSize = config.size;
const handleVisualSize = config.visual;
const positionStyles: Record<string, React.CSSProperties> = {
'top-left': {
@@ -407,8 +414,9 @@ const CropHandle: React.FC<CropHandleProps> = ({ handle, onDragStart, position }
>
<Box
sx={{
width: isCorner ? handleVisualSize : position === 'top' || position === 'bottom' ? 24 : 8,
height: isCorner ? handleVisualSize : position === 'left' || position === 'right' ? 24 : 8,
// Edge handles: 70% of touch target for long dimension, 75% of visual for short dimension
width: isCorner ? handleVisualSize : position === 'top' || position === 'bottom' ? Math.round(handleSize * 0.7) : Math.round(handleVisualSize * 0.75),
height: isCorner ? handleVisualSize : position === 'left' || position === 'right' ? Math.round(handleSize * 0.7) : Math.round(handleVisualSize * 0.75),
backgroundColor: 'white',
borderRadius: isCorner ? '50%' : 1,
boxShadow: '0 2px 4px rgba(0,0,0,0.3)',

View File

@@ -0,0 +1,53 @@
import { getInitialCropForGuidance, type GuidanceType } from '../types';
describe('getInitialCropForGuidance', () => {
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);
});
});
});

View File

@@ -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),
};
}

View File

@@ -82,7 +82,8 @@ export function useImageCrop(options: UseImageCropOptions = {}): UseImageCropRet
const [cropArea, setCropAreaState] = useState<CropArea>(
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<CropHandle | null>(null);