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
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
This commit was merged in pull request #124.
This commit is contained in:
@@ -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';
|
||||
@@ -150,7 +151,15 @@ export const CameraCapture: React.FC<CameraCaptureProps> = ({
|
||||
setCapturedFile(null);
|
||||
setCapturedImageSrc(null);
|
||||
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(() => {
|
||||
// Just reset crop area, keep the captured image
|
||||
@@ -192,6 +201,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 +257,7 @@ export const CameraCapture: React.FC<CameraCaptureProps> = ({
|
||||
return (
|
||||
<CropTool
|
||||
imageSrc={capturedImageSrc}
|
||||
initialCrop={cropInitialArea}
|
||||
lockAspectRatio={guidanceType !== 'none' && guidanceType !== 'vin'}
|
||||
aspectRatio={cropAspectRatio}
|
||||
onConfirm={handleCropConfirm}
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user