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 125 additions and 17 deletions

View File

@@ -12,6 +12,7 @@ import {
DEFAULT_ACCEPTED_FORMATS, DEFAULT_ACCEPTED_FORMATS,
DEFAULT_MAX_FILE_SIZE, DEFAULT_MAX_FILE_SIZE,
GUIDANCE_CONFIGS, GUIDANCE_CONFIGS,
getInitialCropForGuidance,
} from './types'; } from './types';
import { useCameraPermission } from './useCameraPermission'; import { useCameraPermission } from './useCameraPermission';
import { CameraViewfinder } from './CameraViewfinder'; import { CameraViewfinder } from './CameraViewfinder';
@@ -150,7 +151,15 @@ export const CameraCapture: React.FC<CameraCaptureProps> = ({
setCapturedFile(null); setCapturedFile(null);
setCapturedImageSrc(null); setCapturedImageSrc(null);
setCaptureState('viewfinder'); setCaptureState('viewfinder');
}, [capturedImageSrc]);
// Re-request camera if stream tracks are no longer active
const isStreamActive = stream?.getVideoTracks().some(
(track) => track.readyState === 'live'
);
if (!isStreamActive) {
requestPermission();
}
}, [capturedImageSrc, stream, requestPermission]);
const handleCropReset = useCallback(() => { const handleCropReset = useCallback(() => {
// Just reset crop area, keep the captured image // Just reset crop area, keep the captured image
@@ -192,6 +201,9 @@ export const CameraCapture: React.FC<CameraCaptureProps> = ({
const cropAspectRatio = const cropAspectRatio =
guidanceType !== 'none' ? GUIDANCE_CONFIGS[guidanceType].aspectRatio : undefined; guidanceType !== 'none' ? GUIDANCE_CONFIGS[guidanceType].aspectRatio : undefined;
// Calculate crop coordinates centered on the guidance overlay area
const cropInitialArea = getInitialCropForGuidance(guidanceType);
// Render permission error state // Render permission error state
if (permissionState === 'denied' && !useFallback) { if (permissionState === 'denied' && !useFallback) {
return ( return (
@@ -245,6 +257,7 @@ export const CameraCapture: React.FC<CameraCaptureProps> = ({
return ( return (
<CropTool <CropTool
imageSrc={capturedImageSrc} imageSrc={capturedImageSrc}
initialCrop={cropInitialArea}
lockAspectRatio={guidanceType !== 'none' && guidanceType !== 'vin'} lockAspectRatio={guidanceType !== 'none' && guidanceType !== 'vin'}
aspectRatio={cropAspectRatio} aspectRatio={cropAspectRatio}
onConfirm={handleCropConfirm} onConfirm={handleCropConfirm}

View File

@@ -4,7 +4,7 @@
*/ */
import React, { useCallback, useState, useRef, useEffect } from 'react'; 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 CheckIcon from '@mui/icons-material/Check';
import RefreshIcon from '@mui/icons-material/Refresh'; import RefreshIcon from '@mui/icons-material/Refresh';
import ReplayIcon from '@mui/icons-material/Replay'; import ReplayIcon from '@mui/icons-material/Replay';
@@ -15,6 +15,7 @@ import { useImageCrop, type CropHandle } from './useImageCrop';
export const CropTool: React.FC<CropToolProps> = ({ export const CropTool: React.FC<CropToolProps> = ({
imageSrc, imageSrc,
lockAspectRatio = false, lockAspectRatio = false,
initialCrop,
aspectRatio, aspectRatio,
onConfirm, onConfirm,
onReset, onReset,
@@ -25,9 +26,14 @@ export const CropTool: React.FC<CropToolProps> = ({
const imageAreaRef = useRef<HTMLDivElement>(null); const imageAreaRef = useRef<HTMLDivElement>(null);
const [imageMaxHeight, setImageMaxHeight] = useState(0); 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 } = const { cropArea, cropDrawn, isDragging, resetCrop, executeCrop, handleDragStart, handleDrawStart } =
useImageCrop({ useImageCrop({
aspectRatio: lockAspectRatio ? aspectRatio : undefined, aspectRatio: lockAspectRatio ? aspectRatio : undefined,
initialCrop,
}); });
const showCropArea = cropDrawn || (isDragging && cropArea.width > 1 && cropArea.height > 1); const showCropArea = cropDrawn || (isDragging && cropArea.width > 1 && cropArea.height > 1);
@@ -203,22 +209,22 @@ export const CropTool: React.FC<CropToolProps> = ({
onDragStart={handleDragStart} onDragStart={handleDragStart}
sx={{ sx={{
position: 'absolute', position: 'absolute',
inset: 8, inset: handleConfig.size / 2,
cursor: 'move', cursor: 'move',
}} }}
/> />
{/* Corner handles */} {/* Corner handles */}
<CropHandle handle="nw" onDragStart={handleDragStart} position="top-left" /> <CropHandle handle="nw" onDragStart={handleDragStart} position="top-left" config={handleConfig} />
<CropHandle handle="ne" onDragStart={handleDragStart} position="top-right" /> <CropHandle handle="ne" onDragStart={handleDragStart} position="top-right" config={handleConfig} />
<CropHandle handle="sw" onDragStart={handleDragStart} position="bottom-left" /> <CropHandle handle="sw" onDragStart={handleDragStart} position="bottom-left" config={handleConfig} />
<CropHandle handle="se" onDragStart={handleDragStart} position="bottom-right" /> <CropHandle handle="se" onDragStart={handleDragStart} position="bottom-right" config={handleConfig} />
{/* Edge handles */} {/* Edge handles */}
<CropHandle handle="n" onDragStart={handleDragStart} position="top" /> <CropHandle handle="n" onDragStart={handleDragStart} position="top" config={handleConfig} />
<CropHandle handle="s" onDragStart={handleDragStart} position="bottom" /> <CropHandle handle="s" onDragStart={handleDragStart} position="bottom" config={handleConfig} />
<CropHandle handle="w" onDragStart={handleDragStart} position="left" /> <CropHandle handle="w" onDragStart={handleDragStart} position="left" config={handleConfig} />
<CropHandle handle="e" onDragStart={handleDragStart} position="right" /> <CropHandle handle="e" onDragStart={handleDragStart} position="right" config={handleConfig} />
</> </>
)} )}
@@ -326,6 +332,7 @@ export const CropTool: React.FC<CropToolProps> = ({
interface CropHandleProps { interface CropHandleProps {
handle: CropHandle; handle: CropHandle;
onDragStart: (handle: CropHandle, event: React.MouseEvent | React.TouchEvent) => void; onDragStart: (handle: CropHandle, event: React.MouseEvent | React.TouchEvent) => void;
config: { size: number; visual: number };
position: position:
| 'top-left' | 'top-left'
| 'top-right' | 'top-right'
@@ -337,9 +344,9 @@ interface CropHandleProps {
| 'right'; | 'right';
} }
const CropHandle: React.FC<CropHandleProps> = ({ handle, onDragStart, position }) => { const CropHandle: React.FC<CropHandleProps> = ({ handle, onDragStart, position, config }) => {
const handleSize = 24; const handleSize = config.size;
const handleVisualSize = 12; const handleVisualSize = config.visual;
const positionStyles: Record<string, React.CSSProperties> = { const positionStyles: Record<string, React.CSSProperties> = {
'top-left': { 'top-left': {
@@ -407,8 +414,9 @@ const CropHandle: React.FC<CropHandleProps> = ({ handle, onDragStart, position }
> >
<Box <Box
sx={{ sx={{
width: isCorner ? handleVisualSize : position === 'top' || position === 'bottom' ? 24 : 8, // Edge handles: 70% of touch target for long dimension, 75% of visual for short dimension
height: isCorner ? handleVisualSize : position === 'left' || position === 'right' ? 24 : 8, 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', backgroundColor: 'white',
borderRadius: isCorner ? '50%' : 1, borderRadius: isCorner ? '50%' : 1,
boxShadow: '0 2px 4px rgba(0,0,0,0.3)', 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; imageSrc: string;
/** Whether to lock aspect ratio */ /** Whether to lock aspect ratio */
lockAspectRatio?: boolean; lockAspectRatio?: boolean;
/** Initial crop area matching guidance overlay position */
initialCrop?: CropArea;
/** Aspect ratio to lock to (width/height) */ /** Aspect ratio to lock to (width/height) */
aspectRatio?: number; aspectRatio?: number;
/** Callback when crop is confirmed */ /** Callback when crop is confirmed */
@@ -129,3 +131,34 @@ export const DEFAULT_ACCEPTED_FORMATS = [
/** Default max file size (10MB) */ /** Default max file size (10MB) */
export const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; 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>( const [cropArea, setCropAreaState] = useState<CropArea>(
getAspectRatioAdjustedCrop(initialCrop) 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 [isDragging, setIsDragging] = useState(false);
const activeHandleRef = useRef<CropHandle | null>(null); const activeHandleRef = useRef<CropHandle | null>(null);