feat: Improve VIN photo capture camera crop (#123) #124
@@ -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';
|
||||||
@@ -192,6 +193,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 +249,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}
|
||||||
|
|||||||
@@ -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)',
|
||||||
|
|||||||
@@ -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;
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user