Compare commits

...

3 Commits

Author SHA1 Message Date
ee123a2ffd Merge pull request 'feat: Improve VIN photo capture camera crop (#123)' (#124) from issue-123-improve-vin-camera-crop into main
Some checks failed
Deploy to Staging / Deploy to Staging (push) Has been cancelled
Deploy to Staging / Build Images (push) Has been cancelled
Deploy to Staging / Verify Staging (push) Has been cancelled
Deploy to Staging / Notify Staging Ready (push) Has been cancelled
Deploy to Staging / Notify Staging Failure (push) Has been cancelled
Reviewed-on: #124
2026-02-09 00:36:43 +00:00
Eric Gullickson
1ff1931864 fix: re-request camera stream on retake when tracks are inactive (refs #123)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m20s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 51s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
The retake button failed because the stream tracks could become inactive
during the crop phase, but handleRetake never re-acquired the camera.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 20:26:37 -06:00
Eric Gullickson
efc55cd3db feat: improve VIN camera crop overlay-to-crop alignment and touch targets (refs #123)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m20s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 51s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
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 <noreply@anthropic.com>
2026-02-07 20:05:40 -06: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);