From 7c8b6fda2aa5bb57e41d2a5e1419ff4ecebf4102 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 1 Feb 2026 15:05:18 -0600 Subject: [PATCH] feat: add camera capture component (refs #66) Implements a reusable React camera capture component with: - getUserMedia API for camera access on mobile and desktop - Translucent aspect-ratio guidance overlays (VIN ~6:1, receipt ~2:3) - Post-capture crop tool with draggable handles - File input fallback for desktop and unsupported browsers - Support for HEIC, JPEG, PNG (sent as-is to server) - Full mobile responsiveness (320px - 1920px) Co-Authored-By: Claude Opus 4.5 --- frontend/src/shared/CLAUDE.md | 32 ++ .../CameraCapture/CameraCapture.test.tsx | 362 ++++++++++++++++ .../CameraCapture/CameraCapture.tsx | 377 +++++++++++++++++ .../CameraCapture/CameraViewfinder.tsx | 178 ++++++++ .../components/CameraCapture/CropTool.tsx | 396 ++++++++++++++++++ .../CameraCapture/FileInputFallback.tsx | 253 +++++++++++ .../CameraCapture/GuidanceOverlay.tsx | 145 +++++++ .../shared/components/CameraCapture/index.ts | 32 ++ .../shared/components/CameraCapture/types.ts | 131 ++++++ .../CameraCapture/useCameraPermission.ts | 200 +++++++++ .../components/CameraCapture/useImageCrop.ts | 333 +++++++++++++++ 11 files changed, 2439 insertions(+) create mode 100644 frontend/src/shared/CLAUDE.md create mode 100644 frontend/src/shared/components/CameraCapture/CameraCapture.test.tsx create mode 100644 frontend/src/shared/components/CameraCapture/CameraCapture.tsx create mode 100644 frontend/src/shared/components/CameraCapture/CameraViewfinder.tsx create mode 100644 frontend/src/shared/components/CameraCapture/CropTool.tsx create mode 100644 frontend/src/shared/components/CameraCapture/FileInputFallback.tsx create mode 100644 frontend/src/shared/components/CameraCapture/GuidanceOverlay.tsx create mode 100644 frontend/src/shared/components/CameraCapture/index.ts create mode 100644 frontend/src/shared/components/CameraCapture/types.ts create mode 100644 frontend/src/shared/components/CameraCapture/useCameraPermission.ts create mode 100644 frontend/src/shared/components/CameraCapture/useImageCrop.ts diff --git a/frontend/src/shared/CLAUDE.md b/frontend/src/shared/CLAUDE.md new file mode 100644 index 0000000..200ff50 --- /dev/null +++ b/frontend/src/shared/CLAUDE.md @@ -0,0 +1,32 @@ +# frontend/src/shared/ + +Shared components that are reusable across multiple features. + +## Subdirectories + +| Directory | What | When to read | +| --------- | ---- | ------------ | +| `components/CameraCapture/` | Camera capture with crop tool | Building OCR features, image upload | + +## Components + +### CameraCapture + +Full-featured camera capture component with: +- `getUserMedia` API for camera access +- Translucent guidance overlays (VIN, receipt, document) +- Post-capture crop tool +- File input fallback for desktop/unsupported browsers +- HEIC, JPEG, PNG support + +Usage: +```tsx +import { CameraCapture } from '@/shared/components/CameraCapture'; + + handleCapture(file, croppedFile)} + onCancel={() => setShowCamera(false)} + guidanceType="vin" + allowCrop={true} +/> +``` diff --git a/frontend/src/shared/components/CameraCapture/CameraCapture.test.tsx b/frontend/src/shared/components/CameraCapture/CameraCapture.test.tsx new file mode 100644 index 0000000..20fb5b5 --- /dev/null +++ b/frontend/src/shared/components/CameraCapture/CameraCapture.test.tsx @@ -0,0 +1,362 @@ +/** + * @ai-summary Tests for CameraCapture component + * @ai-context Validates camera permission, capture flow, cropping, and file fallback + */ + +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { CameraCapture } from './CameraCapture'; +import { FileInputFallback } from './FileInputFallback'; +import { GuidanceOverlay } from './GuidanceOverlay'; + +// Mock MUI useMediaQuery +jest.mock('@mui/material', () => ({ + ...jest.requireActual('@mui/material'), + useMediaQuery: jest.fn(() => false), +})); + +// Mock navigator.mediaDevices +const mockGetUserMedia = jest.fn(); +const mockEnumerateDevices = jest.fn(); +const mockMediaStream = { + getTracks: () => [{ stop: jest.fn() }], + getVideoTracks: () => [ + { + getSettings: () => ({ width: 1920, height: 1080 }), + stop: jest.fn(), + }, + ], +}; + +beforeAll(() => { + Object.defineProperty(navigator, 'mediaDevices', { + value: { + getUserMedia: mockGetUserMedia, + enumerateDevices: mockEnumerateDevices, + }, + writable: true, + }); +}); + +beforeEach(() => { + jest.clearAllMocks(); + mockEnumerateDevices.mockResolvedValue([ + { kind: 'videoinput', deviceId: 'camera1' }, + ]); +}); + +describe('CameraCapture', () => { + const mockOnCapture = jest.fn(); + const mockOnCancel = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Permission handling', () => { + it('shows loading state while requesting permission', () => { + mockGetUserMedia.mockImplementation(() => new Promise(() => {})); // Never resolves + + render( + + ); + + expect(screen.getByText(/requesting camera access/i)).toBeInTheDocument(); + }); + + it('shows error when permission denied', async () => { + mockGetUserMedia.mockRejectedValue( + new DOMException('Permission denied', 'NotAllowedError') + ); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText(/camera permission/i)).toBeInTheDocument(); + }); + + expect(screen.getByRole('button', { name: /upload file/i })).toBeInTheDocument(); + }); + + it('shows error when camera unavailable', async () => { + mockGetUserMedia.mockRejectedValue( + new DOMException('No camera', 'NotFoundError') + ); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText(/no camera found/i)).toBeInTheDocument(); + }); + }); + }); + + describe('Viewfinder', () => { + it('shows viewfinder when camera access granted', async () => { + mockGetUserMedia.mockResolvedValue(mockMediaStream); + + render( + + ); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /take photo/i })).toBeInTheDocument(); + }); + }); + + it('shows cancel button in viewfinder', async () => { + mockGetUserMedia.mockResolvedValue(mockMediaStream); + + render( + + ); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /cancel capture/i })).toBeInTheDocument(); + }); + }); + + it('calls onCancel when cancel button clicked', async () => { + mockGetUserMedia.mockResolvedValue(mockMediaStream); + + render( + + ); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /cancel capture/i })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: /cancel capture/i })); + expect(mockOnCancel).toHaveBeenCalledTimes(1); + }); + }); + + describe('Guidance overlay', () => { + it('shows VIN guidance when guidanceType is vin', async () => { + mockGetUserMedia.mockResolvedValue(mockMediaStream); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText(/position vin/i)).toBeInTheDocument(); + }); + }); + + it('shows receipt guidance when guidanceType is receipt', async () => { + mockGetUserMedia.mockResolvedValue(mockMediaStream); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText(/position receipt/i)).toBeInTheDocument(); + }); + }); + }); + + describe('File fallback', () => { + it('shows upload file button in viewfinder', async () => { + mockGetUserMedia.mockResolvedValue(mockMediaStream); + + render( + + ); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /upload file/i })).toBeInTheDocument(); + }); + }); + + it('switches to file fallback when upload file clicked', async () => { + mockGetUserMedia.mockResolvedValue(mockMediaStream); + + render( + + ); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /upload file/i })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: /upload file/i })); + + await waitFor(() => { + expect(screen.getByText(/upload image/i)).toBeInTheDocument(); + }); + }); + }); +}); + +describe('FileInputFallback', () => { + const mockOnFileSelect = jest.fn(); + const mockOnCancel = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders upload area', () => { + render( + + ); + + expect(screen.getByText(/drag and drop/i)).toBeInTheDocument(); + }); + + it('shows accepted formats', () => { + render( + + ); + + expect(screen.getByText(/jpeg, png, heic/i)).toBeInTheDocument(); + }); + + it('shows max file size', () => { + render( + + ); + + expect(screen.getByText(/10mb/i)).toBeInTheDocument(); + }); + + it('calls onCancel when cancel clicked', () => { + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /cancel/i })); + expect(mockOnCancel).toHaveBeenCalledTimes(1); + }); + + it('shows error for invalid file type', async () => { + render( + + ); + + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + const invalidFile = new File(['test'], 'test.txt', { type: 'text/plain' }); + + Object.defineProperty(input, 'files', { + value: [invalidFile], + }); + + fireEvent.change(input); + + await waitFor(() => { + expect(screen.getByText(/invalid file type/i)).toBeInTheDocument(); + }); + }); + + it('shows error for file too large', async () => { + render( + + ); + + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + const largeFile = new File(['x'.repeat(2048)], 'large.jpg', { + type: 'image/jpeg', + }); + + Object.defineProperty(input, 'files', { + value: [largeFile], + }); + + fireEvent.change(input); + + await waitFor(() => { + expect(screen.getByText(/file too large/i)).toBeInTheDocument(); + }); + }); + + it('calls onFileSelect with valid file', async () => { + render( + + ); + + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + const validFile = new File(['test'], 'test.jpg', { type: 'image/jpeg' }); + + Object.defineProperty(input, 'files', { + value: [validFile], + }); + + fireEvent.change(input); + + await waitFor(() => { + expect(mockOnFileSelect).toHaveBeenCalledWith(validFile); + }); + }); +}); + +describe('GuidanceOverlay', () => { + it('renders nothing when type is none', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders VIN guidance with correct description', () => { + render(); + expect(screen.getByText(/position vin plate/i)).toBeInTheDocument(); + }); + + it('renders receipt guidance with correct description', () => { + render(); + expect(screen.getByText(/position receipt/i)).toBeInTheDocument(); + }); + + it('renders document guidance with correct description', () => { + render(); + expect(screen.getByText(/position document/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/shared/components/CameraCapture/CameraCapture.tsx b/frontend/src/shared/components/CameraCapture/CameraCapture.tsx new file mode 100644 index 0000000..a876c46 --- /dev/null +++ b/frontend/src/shared/components/CameraCapture/CameraCapture.tsx @@ -0,0 +1,377 @@ +/** + * @ai-summary Main camera capture component with viewfinder, crop tool, and file fallback + * @ai-context Orchestrates camera access, photo capture, cropping, and file selection + */ + +import React, { useState, useCallback, useEffect } from 'react'; +import { Box, Alert, Button, Typography, CircularProgress } from '@mui/material'; +import CameraAltIcon from '@mui/icons-material/CameraAlt'; +import UploadFileIcon from '@mui/icons-material/UploadFile'; +import type { CameraCaptureProps, CaptureState } from './types'; +import { + DEFAULT_ACCEPTED_FORMATS, + DEFAULT_MAX_FILE_SIZE, + GUIDANCE_CONFIGS, +} from './types'; +import { useCameraPermission } from './useCameraPermission'; +import { CameraViewfinder } from './CameraViewfinder'; +import { CropTool } from './CropTool'; +import { FileInputFallback } from './FileInputFallback'; + +export const CameraCapture: React.FC = ({ + onCapture, + onCancel, + guidanceType = 'none', + allowCrop = true, + maxFileSize = DEFAULT_MAX_FILE_SIZE, + acceptedFormats = DEFAULT_ACCEPTED_FORMATS, +}) => { + const [captureState, setCaptureState] = useState('idle'); + const [capturedImageSrc, setCapturedImageSrc] = useState(null); + const [capturedFile, setCapturedFile] = useState(null); + const [useFallback, setUseFallback] = useState(false); + + const { + permissionState, + stream, + error: cameraError, + facingMode, + hasMultipleCameras, + requestPermission, + switchCamera, + stopStream, + } = useCameraPermission(); + + // Request camera permission on mount + useEffect(() => { + if (captureState === 'idle' && !useFallback) { + setCaptureState('viewfinder'); + requestPermission(); + } + }, [captureState, useFallback, requestPermission]); + + // Clean up on unmount + useEffect(() => { + return () => { + stopStream(); + if (capturedImageSrc) { + URL.revokeObjectURL(capturedImageSrc); + } + }; + }, [stopStream, capturedImageSrc]); + + const handleCapture = useCallback(() => { + if (!stream) return; + + // Get video element from stream + const videoTracks = stream.getVideoTracks(); + if (videoTracks.length === 0) return; + + const videoTrack = videoTracks[0]; + const settings = videoTrack.getSettings(); + + // Create canvas for capture + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // Find the video element to capture from + const videoElements = document.querySelectorAll('video'); + let sourceVideo: HTMLVideoElement | null = null; + + for (const video of videoElements) { + if (video.srcObject === stream) { + sourceVideo = video; + break; + } + } + + if (!sourceVideo) return; + + // Set canvas size to video dimensions + canvas.width = settings.width || sourceVideo.videoWidth || 1920; + canvas.height = settings.height || sourceVideo.videoHeight || 1080; + + // Draw video frame to canvas + ctx.drawImage(sourceVideo, 0, 0, canvas.width, canvas.height); + + // Convert to blob + canvas.toBlob( + (blob) => { + if (!blob) return; + + // Create file from blob + const file = new File([blob], `capture-${Date.now()}.jpg`, { + type: 'image/jpeg', + }); + + setCapturedFile(file); + setCapturedImageSrc(URL.createObjectURL(blob)); + + if (allowCrop) { + setCaptureState('cropping'); + } else { + // Skip cropping, directly complete + onCapture(file); + stopStream(); + } + }, + 'image/jpeg', + 0.92 + ); + }, [stream, allowCrop, onCapture, stopStream]); + + const handleCropConfirm = useCallback( + (croppedBlob: Blob) => { + if (!capturedFile) return; + + const croppedFile = new File( + [croppedBlob], + `cropped-${capturedFile.name}`, + { type: croppedBlob.type } + ); + + onCapture(capturedFile, croppedFile); + stopStream(); + }, + [capturedFile, onCapture, stopStream] + ); + + const handleCropSkip = useCallback(() => { + if (!capturedFile) return; + onCapture(capturedFile); + stopStream(); + }, [capturedFile, onCapture, stopStream]); + + const handleRetake = useCallback(() => { + if (capturedImageSrc) { + URL.revokeObjectURL(capturedImageSrc); + } + setCapturedFile(null); + setCapturedImageSrc(null); + setCaptureState('viewfinder'); + }, [capturedImageSrc]); + + const handleCropReset = useCallback(() => { + // Just reset crop area, keep the captured image + }, []); + + const handleCancel = useCallback(() => { + stopStream(); + if (capturedImageSrc) { + URL.revokeObjectURL(capturedImageSrc); + } + onCancel(); + }, [stopStream, capturedImageSrc, onCancel]); + + const handleFileSelect = useCallback( + (file: File) => { + setCapturedFile(file); + setCapturedImageSrc(URL.createObjectURL(file)); + + if (allowCrop) { + setCaptureState('cropping'); + } else { + onCapture(file); + } + }, + [allowCrop, onCapture] + ); + + const handleSwitchToFallback = useCallback(() => { + stopStream(); + setUseFallback(true); + }, [stopStream]); + + const handleSwitchToCamera = useCallback(() => { + setUseFallback(false); + setCaptureState('idle'); + }, []); + + // Get aspect ratio for crop tool if guidance type is set + const cropAspectRatio = + guidanceType !== 'none' ? GUIDANCE_CONFIGS[guidanceType].aspectRatio : undefined; + + // Render permission error state + if (permissionState === 'denied' && !useFallback) { + return ( + + ); + } + + // Render unavailable state + if (permissionState === 'unavailable' && !useFallback) { + return ( + + ); + } + + // Render file input fallback + if (useFallback) { + return ( + + + {/* Option to switch back to camera if available */} + {permissionState !== 'unavailable' && permissionState !== 'denied' && ( + + + + )} + + ); + } + + // Render crop tool + if (captureState === 'cropping' && capturedImageSrc) { + return ( + + ); + } + + // Render loading state + if (!stream && permissionState === 'prompt') { + return ( + + + Requesting camera access... + + + ); + } + + // Render viewfinder + return ( + + + + {/* Fallback option button */} + + + + + ); +}; + +interface PermissionErrorViewProps { + error: string; + onUseFileInput: () => void; + onCancel: () => void; +} + +const PermissionErrorView: React.FC = ({ + error, + onUseFileInput, + onCancel, +}) => ( + + + {error} + + + + You can still upload images from your device + + + + + + + + +); + +export default CameraCapture; diff --git a/frontend/src/shared/components/CameraCapture/CameraViewfinder.tsx b/frontend/src/shared/components/CameraCapture/CameraViewfinder.tsx new file mode 100644 index 0000000..5261986 --- /dev/null +++ b/frontend/src/shared/components/CameraCapture/CameraViewfinder.tsx @@ -0,0 +1,178 @@ +/** + * @ai-summary Camera viewfinder with live preview and capture controls + * @ai-context Displays video stream with guidance overlay and capture/cancel buttons + */ + +import React, { useRef, useEffect } from 'react'; +import { Box, IconButton, Typography } from '@mui/material'; +import CameraAltIcon from '@mui/icons-material/CameraAlt'; +import CloseIcon from '@mui/icons-material/Close'; +import CameraswitchIcon from '@mui/icons-material/Cameraswitch'; +import type { CameraViewfinderProps } from './types'; +import { GuidanceOverlay } from './GuidanceOverlay'; + +export const CameraViewfinder: React.FC = ({ + stream, + guidanceType, + onCapture, + onCancel, + onSwitchCamera, + canSwitchCamera, + facingMode, +}) => { + const videoRef = useRef(null); + + // Attach stream to video element + useEffect(() => { + if (videoRef.current && stream) { + videoRef.current.srcObject = stream; + } + + return () => { + if (videoRef.current) { + videoRef.current.srcObject = null; + } + }; + }, [stream]); + + return ( + + {/* Video preview */} + + + + {/* Bottom controls */} + + {/* Capture button */} + + + + + + Tap to capture + + + + ); +}; + +export default CameraViewfinder; diff --git a/frontend/src/shared/components/CameraCapture/CropTool.tsx b/frontend/src/shared/components/CameraCapture/CropTool.tsx new file mode 100644 index 0000000..1d3a750 --- /dev/null +++ b/frontend/src/shared/components/CameraCapture/CropTool.tsx @@ -0,0 +1,396 @@ +/** + * @ai-summary Post-capture crop tool with draggable handles + * @ai-context Allows user to adjust crop area with touch/mouse, confirm or retake + */ + +import React, { useCallback, useState } from 'react'; +import { Box, IconButton, Button, Typography, CircularProgress } from '@mui/material'; +import CheckIcon from '@mui/icons-material/Check'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import ReplayIcon from '@mui/icons-material/Replay'; +import SkipNextIcon from '@mui/icons-material/SkipNext'; +import type { CropToolProps } from './types'; +import { useImageCrop, type CropHandle } from './useImageCrop'; + +export const CropTool: React.FC = ({ + imageSrc, + lockAspectRatio = false, + aspectRatio, + onConfirm, + onReset, + onRetake, + onSkip, +}) => { + const [isProcessing, setIsProcessing] = useState(false); + + const { cropArea, isDragging, resetCrop, executeCrop, handleDragStart } = + useImageCrop({ + aspectRatio: lockAspectRatio ? aspectRatio : undefined, + }); + + const handleConfirm = useCallback(async () => { + setIsProcessing(true); + try { + const croppedBlob = await executeCrop(imageSrc); + onConfirm(croppedBlob); + } catch (error) { + console.error('Failed to crop image:', error); + // On error, skip cropping and use original + onSkip(); + } finally { + setIsProcessing(false); + } + }, [executeCrop, imageSrc, onConfirm, onSkip]); + + const handleReset = useCallback(() => { + resetCrop(); + onReset(); + }, [resetCrop, onReset]); + + return ( + + {/* Image with crop overlay */} + + + {/* Source image */} + Captured + + {/* Dark overlay outside crop area */} + + {/* Top overlay */} + + {/* Bottom overlay */} + + {/* Left overlay */} + + {/* Right overlay */} + + + + {/* Crop area with handles */} + + {/* Move handle (center area) */} + + + {/* Corner handles */} + + + + + + {/* Edge handles */} + + + + + + {/* Grid lines for alignment */} + + {Array.from({ length: 9 }).map((_, i) => ( + + ))} + + + + + + {/* Instructions */} + + + Drag to adjust crop area + + + + {/* Action buttons */} + + + + + + + + + {isProcessing ? ( + + ) : ( + + )} + + + + ); +}; + +interface CropHandleProps { + handle: CropHandle; + onDragStart: (handle: CropHandle, event: React.MouseEvent | React.TouchEvent) => void; + position: + | 'top-left' + | 'top-right' + | 'bottom-left' + | 'bottom-right' + | 'top' + | 'bottom' + | 'left' + | 'right'; +} + +const CropHandle: React.FC = ({ handle, onDragStart, position }) => { + const handleSize = 24; + const handleVisualSize = 12; + + const positionStyles: Record = { + 'top-left': { + top: -handleSize / 2, + left: -handleSize / 2, + cursor: 'nwse-resize', + }, + 'top-right': { + top: -handleSize / 2, + right: -handleSize / 2, + cursor: 'nesw-resize', + }, + 'bottom-left': { + bottom: -handleSize / 2, + left: -handleSize / 2, + cursor: 'nesw-resize', + }, + 'bottom-right': { + bottom: -handleSize / 2, + right: -handleSize / 2, + cursor: 'nwse-resize', + }, + top: { + top: -handleSize / 2, + left: '50%', + transform: 'translateX(-50%)', + cursor: 'ns-resize', + }, + bottom: { + bottom: -handleSize / 2, + left: '50%', + transform: 'translateX(-50%)', + cursor: 'ns-resize', + }, + left: { + top: '50%', + left: -handleSize / 2, + transform: 'translateY(-50%)', + cursor: 'ew-resize', + }, + right: { + top: '50%', + right: -handleSize / 2, + transform: 'translateY(-50%)', + cursor: 'ew-resize', + }, + }; + + const isCorner = position.includes('-'); + + return ( + onDragStart(handle, e)} + onTouchStart={(e) => onDragStart(handle, e)} + sx={{ + position: 'absolute', + width: handleSize, + height: handleSize, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + zIndex: 10, + ...positionStyles[position], + }} + > + + + ); +}; + +interface CropHandleAreaProps { + handle: CropHandle; + onDragStart: (handle: CropHandle, event: React.MouseEvent | React.TouchEvent) => void; + sx?: React.CSSProperties & { [key: string]: unknown }; +} + +const CropHandleArea: React.FC = ({ handle, onDragStart, sx }) => { + return ( + onDragStart(handle, e)} + onTouchStart={(e) => onDragStart(handle, e)} + sx={sx} + /> + ); +}; + +export default CropTool; diff --git a/frontend/src/shared/components/CameraCapture/FileInputFallback.tsx b/frontend/src/shared/components/CameraCapture/FileInputFallback.tsx new file mode 100644 index 0000000..023fb17 --- /dev/null +++ b/frontend/src/shared/components/CameraCapture/FileInputFallback.tsx @@ -0,0 +1,253 @@ +/** + * @ai-summary File input fallback for desktop and unsupported browsers + * @ai-context Provides drag-drop and click-to-browse file selection + */ + +import React, { useRef, useState, useCallback } from 'react'; +import { Box, Typography, Button, Alert } from '@mui/material'; +import CloudUploadIcon from '@mui/icons-material/CloudUpload'; +import CloseIcon from '@mui/icons-material/Close'; +import type { FileInputFallbackProps } from './types'; + +export const FileInputFallback: React.FC = ({ + onFileSelect, + onCancel, + acceptedFormats, + maxFileSize, +}) => { + const inputRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [error, setError] = useState(null); + + const validateFile = useCallback( + (file: File): string | null => { + // Check file type + const isValidType = + acceptedFormats.length === 0 || + acceptedFormats.some((format) => { + // Handle HEIC/HEIF which may have different MIME types + if (format === 'image/heic' || format === 'image/heif') { + return ( + file.type === 'image/heic' || + file.type === 'image/heif' || + file.name.toLowerCase().endsWith('.heic') || + file.name.toLowerCase().endsWith('.heif') + ); + } + return file.type === format; + }); + + if (!isValidType) { + return `Invalid file type. Accepted formats: ${acceptedFormats.join(', ')}`; + } + + // Check file size + if (file.size > maxFileSize) { + const maxSizeMB = (maxFileSize / (1024 * 1024)).toFixed(1); + return `File too large. Maximum size: ${maxSizeMB}MB`; + } + + return null; + }, + [acceptedFormats, maxFileSize] + ); + + const handleFile = useCallback( + (file: File) => { + setError(null); + const validationError = validateFile(file); + + if (validationError) { + setError(validationError); + return; + } + + onFileSelect(file); + }, + [validateFile, onFileSelect] + ); + + const handleInputChange = useCallback( + (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + handleFile(file); + } + // Reset input so same file can be selected again + if (inputRef.current) { + inputRef.current.value = ''; + } + }, + [handleFile] + ); + + const handleDragOver = useCallback((event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + setIsDragging(true); + }, []); + + const handleDragLeave = useCallback((event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + setIsDragging(false); + }, []); + + const handleDrop = useCallback( + (event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + setIsDragging(false); + + const file = event.dataTransfer.files[0]; + if (file) { + handleFile(file); + } + }, + [handleFile] + ); + + const handleClick = useCallback(() => { + inputRef.current?.click(); + }, []); + + // Generate accept string for input + const acceptString = acceptedFormats.join(','); + + return ( + + {/* Header */} + + Upload Image + + + + {/* Drop zone */} + + + + + + {isDragging + ? 'Drop image here' + : 'Drag and drop an image, or click to browse'} + + + + Supported formats: JPEG, PNG, HEIC + + + + Maximum size: {(maxFileSize / (1024 * 1024)).toFixed(0)}MB + + + + {error && ( + + {error} + + )} + + {/* Hidden file input */} + + + + {/* Mobile-friendly button */} + + + + + ); +}; + +export default FileInputFallback; diff --git a/frontend/src/shared/components/CameraCapture/GuidanceOverlay.tsx b/frontend/src/shared/components/CameraCapture/GuidanceOverlay.tsx new file mode 100644 index 0000000..a40460b --- /dev/null +++ b/frontend/src/shared/components/CameraCapture/GuidanceOverlay.tsx @@ -0,0 +1,145 @@ +/** + * @ai-summary Translucent guidance overlay for camera viewfinder + * @ai-context Displays aspect-ratio guides for VIN (~6:1), receipt (~2:3), and documents + */ + +import React from 'react'; +import { Box, Typography } from '@mui/material'; +import type { GuidanceOverlayProps, GuidanceType } from './types'; +import { GUIDANCE_CONFIGS } from './types'; + +/** Calculate overlay dimensions based on guidance type and container */ +function getOverlayStyles(type: GuidanceType): React.CSSProperties { + if (type === 'none') { + return { display: 'none' }; + } + + const config = GUIDANCE_CONFIGS[type]; + + // For VIN (wide), use width-constrained layout + // For receipt/document (tall), use height-constrained layout + if (config.aspectRatio > 1) { + // Wide aspect ratio (VIN) + return { + width: '85%', + height: 'auto', + aspectRatio: `${config.aspectRatio}`, + maxHeight: '30%', + }; + } else { + // Tall aspect ratio (receipt, document) + return { + height: '70%', + width: 'auto', + aspectRatio: `${config.aspectRatio}`, + maxWidth: '85%', + }; + } +} + +export const GuidanceOverlay: React.FC = ({ type }) => { + if (type === 'none') { + return null; + } + + const config = GUIDANCE_CONFIGS[type]; + const overlayStyles = getOverlayStyles(type); + + return ( + + {/* Guide box */} + + {/* Corner indicators */} + + + + + + + {/* Instruction text */} + + {config.description} + + + ); +}; + +interface CornerIndicatorProps { + position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; +} + +const CornerIndicator: React.FC = ({ position }) => { + const size = 20; + const thickness = 3; + + const positionStyles: Record = { + 'top-left': { + top: -thickness / 2, + left: -thickness / 2, + borderTop: `${thickness}px solid white`, + borderLeft: `${thickness}px solid white`, + }, + 'top-right': { + top: -thickness / 2, + right: -thickness / 2, + borderTop: `${thickness}px solid white`, + borderRight: `${thickness}px solid white`, + }, + 'bottom-left': { + bottom: -thickness / 2, + left: -thickness / 2, + borderBottom: `${thickness}px solid white`, + borderLeft: `${thickness}px solid white`, + }, + 'bottom-right': { + bottom: -thickness / 2, + right: -thickness / 2, + borderBottom: `${thickness}px solid white`, + borderRight: `${thickness}px solid white`, + }, + }; + + return ( + + ); +}; + +export default GuidanceOverlay; diff --git a/frontend/src/shared/components/CameraCapture/index.ts b/frontend/src/shared/components/CameraCapture/index.ts new file mode 100644 index 0000000..405a6dd --- /dev/null +++ b/frontend/src/shared/components/CameraCapture/index.ts @@ -0,0 +1,32 @@ +/** + * @ai-summary Barrel export for CameraCapture component + * @ai-context Exports main component, types, and sub-components + */ + +export { CameraCapture, default } from './CameraCapture'; +export { CameraViewfinder } from './CameraViewfinder'; +export { GuidanceOverlay } from './GuidanceOverlay'; +export { CropTool } from './CropTool'; +export { FileInputFallback } from './FileInputFallback'; +export { useCameraPermission } from './useCameraPermission'; +export { useImageCrop } from './useImageCrop'; + +export type { + CameraCaptureProps, + CameraViewfinderProps, + GuidanceOverlayProps, + CropToolProps, + FileInputFallbackProps, + GuidanceType, + PermissionState, + CaptureState, + FacingMode, + CropArea, + GuidanceConfig, +} from './types'; + +export { + GUIDANCE_CONFIGS, + DEFAULT_ACCEPTED_FORMATS, + DEFAULT_MAX_FILE_SIZE, +} from './types'; diff --git a/frontend/src/shared/components/CameraCapture/types.ts b/frontend/src/shared/components/CameraCapture/types.ts new file mode 100644 index 0000000..5909d2d --- /dev/null +++ b/frontend/src/shared/components/CameraCapture/types.ts @@ -0,0 +1,131 @@ +/** + * @ai-summary Type definitions for CameraCapture component + * @ai-context Defines props, state, and configuration types for camera capture functionality + */ + +/** Guidance overlay type determining aspect ratio and visual hints */ +export type GuidanceType = 'vin' | 'receipt' | 'document' | 'none'; + +/** Camera permission state */ +export type PermissionState = 'prompt' | 'granted' | 'denied' | 'unavailable'; + +/** Camera capture workflow state */ +export type CaptureState = 'idle' | 'viewfinder' | 'captured' | 'cropping'; + +/** Camera facing mode */ +export type FacingMode = 'user' | 'environment'; + +/** Props for the main CameraCapture component */ +export interface CameraCaptureProps { + /** Callback when capture is complete with original file and optional cropped file */ + onCapture: (file: File, croppedFile?: File) => void; + /** Callback when user cancels capture */ + onCancel: () => void; + /** Type of guidance overlay to display */ + guidanceType?: GuidanceType; + /** Whether to show crop tool after capture */ + allowCrop?: boolean; + /** Maximum file size in bytes (default 10MB) */ + maxFileSize?: number; + /** Accepted MIME types */ + acceptedFormats?: string[]; +} + +/** Props for CameraViewfinder component */ +export interface CameraViewfinderProps { + /** Currently active media stream */ + stream: MediaStream | null; + /** Guidance overlay type */ + guidanceType: GuidanceType; + /** Callback when capture button is pressed */ + onCapture: () => void; + /** Callback when user cancels */ + onCancel: () => void; + /** Callback to switch camera */ + onSwitchCamera: () => void; + /** Whether front/rear camera switch is available */ + canSwitchCamera: boolean; + /** Current camera facing mode */ + facingMode: FacingMode; +} + +/** Props for GuidanceOverlay component */ +export interface GuidanceOverlayProps { + /** Type of guidance overlay */ + type: GuidanceType; +} + +/** Props for CropTool component */ +export interface CropToolProps { + /** Source image to crop */ + imageSrc: string; + /** Whether to lock aspect ratio */ + lockAspectRatio?: boolean; + /** Aspect ratio to lock to (width/height) */ + aspectRatio?: number; + /** Callback when crop is confirmed */ + onConfirm: (croppedBlob: Blob) => void; + /** Callback to reset crop */ + onReset: () => void; + /** Callback to cancel and retake */ + onRetake: () => void; + /** Callback when user skips cropping */ + onSkip: () => void; +} + +/** Props for FileInputFallback component */ +export interface FileInputFallbackProps { + /** Callback when file is selected */ + onFileSelect: (file: File) => void; + /** Callback when user cancels */ + onCancel: () => void; + /** Accepted MIME types */ + acceptedFormats: string[]; + /** Maximum file size in bytes */ + maxFileSize: number; +} + +/** Crop area coordinates and dimensions */ +export interface CropArea { + x: number; + y: number; + width: number; + height: number; +} + +/** Guidance overlay aspect ratio configuration */ +export interface GuidanceConfig { + aspectRatio: number; + label: string; + description: string; +} + +/** Guidance configurations by type */ +export const GUIDANCE_CONFIGS: Record, GuidanceConfig> = { + vin: { + aspectRatio: 6, + label: 'VIN', + description: 'Position VIN plate within the guide', + }, + receipt: { + aspectRatio: 2 / 3, + label: 'Receipt', + description: 'Position receipt within the guide', + }, + document: { + aspectRatio: 8.5 / 11, + label: 'Document', + description: 'Position document within the guide', + }, +}; + +/** Default accepted formats */ +export const DEFAULT_ACCEPTED_FORMATS = [ + 'image/jpeg', + 'image/png', + 'image/heic', + 'image/heif', +]; + +/** Default max file size (10MB) */ +export const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; diff --git a/frontend/src/shared/components/CameraCapture/useCameraPermission.ts b/frontend/src/shared/components/CameraCapture/useCameraPermission.ts new file mode 100644 index 0000000..35aff81 --- /dev/null +++ b/frontend/src/shared/components/CameraCapture/useCameraPermission.ts @@ -0,0 +1,200 @@ +/** + * @ai-summary Hook for managing camera permission and media stream + * @ai-context Handles getUserMedia API, permission state, and camera switching + */ + +import { useState, useCallback, useRef, useEffect } from 'react'; +import type { PermissionState, FacingMode } from './types'; + +interface UseCameraPermissionOptions { + /** Initial facing mode preference */ + initialFacingMode?: FacingMode; +} + +interface UseCameraPermissionReturn { + /** Current permission state */ + permissionState: PermissionState; + /** Active media stream */ + stream: MediaStream | null; + /** Error message if any */ + error: string | null; + /** Current camera facing mode */ + facingMode: FacingMode; + /** Whether the device has multiple cameras */ + hasMultipleCameras: boolean; + /** Request camera access */ + requestPermission: () => Promise; + /** Switch between front and rear camera */ + switchCamera: () => Promise; + /** Stop the media stream */ + stopStream: () => void; +} + +/** + * Hook for managing camera permissions and media streams + */ +export function useCameraPermission( + options: UseCameraPermissionOptions = {} +): UseCameraPermissionReturn { + const { initialFacingMode = 'environment' } = options; + + const [permissionState, setPermissionState] = useState('prompt'); + const [stream, setStream] = useState(null); + const [error, setError] = useState(null); + const [facingMode, setFacingMode] = useState(initialFacingMode); + const [hasMultipleCameras, setHasMultipleCameras] = useState(false); + + const streamRef = useRef(null); + + // Check for multiple cameras on mount + useEffect(() => { + const checkCameras = async () => { + try { + if (!navigator.mediaDevices?.enumerateDevices) { + return; + } + const devices = await navigator.mediaDevices.enumerateDevices(); + const videoInputs = devices.filter((d) => d.kind === 'videoinput'); + setHasMultipleCameras(videoInputs.length > 1); + } catch { + // Silently fail - camera enumeration is not critical + } + }; + checkCameras(); + }, []); + + // Clean up stream on unmount + useEffect(() => { + return () => { + if (streamRef.current) { + streamRef.current.getTracks().forEach((track) => track.stop()); + } + }; + }, []); + + const stopStream = useCallback(() => { + if (streamRef.current) { + streamRef.current.getTracks().forEach((track) => track.stop()); + streamRef.current = null; + setStream(null); + } + }, []); + + const requestPermission = useCallback(async () => { + // Check if getUserMedia is available + if (!navigator.mediaDevices?.getUserMedia) { + setPermissionState('unavailable'); + setError('Camera access is not supported in this browser'); + return; + } + + // Stop any existing stream + stopStream(); + setError(null); + + try { + const constraints: MediaStreamConstraints = { + video: { + facingMode: { ideal: facingMode }, + width: { ideal: 1920 }, + height: { ideal: 1080 }, + }, + audio: false, + }; + + const mediaStream = await navigator.mediaDevices.getUserMedia(constraints); + streamRef.current = mediaStream; + setStream(mediaStream); + setPermissionState('granted'); + + // Re-check for multiple cameras after permission granted + const devices = await navigator.mediaDevices.enumerateDevices(); + const videoInputs = devices.filter((d) => d.kind === 'videoinput'); + setHasMultipleCameras(videoInputs.length > 1); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Unknown error'; + + if ( + err instanceof DOMException && + (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') + ) { + setPermissionState('denied'); + setError('Camera permission was denied. Please enable camera access in your browser settings.'); + } else if ( + err instanceof DOMException && + (err.name === 'NotFoundError' || err.name === 'DevicesNotFoundError') + ) { + setPermissionState('unavailable'); + setError('No camera found on this device'); + } else if ( + err instanceof DOMException && + (err.name === 'NotReadableError' || err.name === 'TrackStartError') + ) { + setPermissionState('unavailable'); + setError('Camera is already in use by another application'); + } else { + setPermissionState('unavailable'); + setError(`Failed to access camera: ${errorMessage}`); + } + } + }, [facingMode, stopStream]); + + const switchCamera = useCallback(async () => { + const newFacingMode: FacingMode = facingMode === 'environment' ? 'user' : 'environment'; + setFacingMode(newFacingMode); + + // If we have an active stream, restart with new facing mode + if (stream) { + stopStream(); + + try { + const constraints: MediaStreamConstraints = { + video: { + facingMode: { exact: newFacingMode }, + width: { ideal: 1920 }, + height: { ideal: 1080 }, + }, + audio: false, + }; + + const mediaStream = await navigator.mediaDevices.getUserMedia(constraints); + streamRef.current = mediaStream; + setStream(mediaStream); + } catch { + // If exact fails, try ideal + try { + const constraints: MediaStreamConstraints = { + video: { + facingMode: { ideal: newFacingMode }, + width: { ideal: 1920 }, + height: { ideal: 1080 }, + }, + audio: false, + }; + + const mediaStream = await navigator.mediaDevices.getUserMedia(constraints); + streamRef.current = mediaStream; + setStream(mediaStream); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Unknown error'; + setError(`Failed to switch camera: ${errorMessage}`); + // Revert facing mode + setFacingMode(facingMode); + } + } + } + }, [facingMode, stream, stopStream]); + + return { + permissionState, + stream, + error, + facingMode, + hasMultipleCameras, + requestPermission, + switchCamera, + stopStream, + }; +} + +export default useCameraPermission; diff --git a/frontend/src/shared/components/CameraCapture/useImageCrop.ts b/frontend/src/shared/components/CameraCapture/useImageCrop.ts new file mode 100644 index 0000000..58d69d4 --- /dev/null +++ b/frontend/src/shared/components/CameraCapture/useImageCrop.ts @@ -0,0 +1,333 @@ +/** + * @ai-summary Hook for managing image cropping state and operations + * @ai-context Handles crop area manipulation, touch/mouse interactions, and crop execution + */ + +import { useState, useCallback, useRef, useEffect } from 'react'; +import type { CropArea } from './types'; + +interface UseImageCropOptions { + /** Initial crop area as percentage of image */ + initialCrop?: CropArea; + /** Lock aspect ratio (width/height) */ + aspectRatio?: number; + /** Minimum crop size as percentage */ + minSize?: number; +} + +interface UseImageCropReturn { + /** Current crop area */ + cropArea: CropArea; + /** Whether user is actively dragging */ + isDragging: boolean; + /** Set crop area */ + setCropArea: (area: CropArea) => void; + /** Reset crop to initial/default */ + resetCrop: () => void; + /** Execute crop and return cropped blob */ + executeCrop: (imageSrc: string, mimeType?: string) => Promise; + /** Handle drag start for crop handles */ + handleDragStart: (handle: CropHandle, event: React.MouseEvent | React.TouchEvent) => void; + /** Handle move during drag */ + handleMove: (event: MouseEvent | TouchEvent) => void; + /** Handle drag end */ + handleDragEnd: () => void; +} + +export type CropHandle = + | 'move' + | 'nw' + | 'n' + | 'ne' + | 'e' + | 'se' + | 's' + | 'sw' + | 'w'; + +/** + * Hook for managing image cropping + */ +export function useImageCrop(options: UseImageCropOptions = {}): UseImageCropReturn { + const { aspectRatio, minSize = 10 } = options; + + const defaultCrop: CropArea = { + x: 10, + y: 10, + width: 80, + height: 80, + }; + + const initialCrop = options.initialCrop || defaultCrop; + + // Apply aspect ratio to initial crop if needed + const getAspectRatioAdjustedCrop = useCallback( + (crop: CropArea): CropArea => { + if (!aspectRatio) return crop; + + // Adjust height based on width and aspect ratio + const newHeight = crop.width / aspectRatio; + return { + ...crop, + height: Math.min(newHeight, 100 - crop.y), + }; + }, + [aspectRatio] + ); + + const [cropArea, setCropAreaState] = useState( + getAspectRatioAdjustedCrop(initialCrop) + ); + const [isDragging, setIsDragging] = useState(false); + + const activeHandleRef = useRef(null); + const startPosRef = useRef({ x: 0, y: 0 }); + const startCropRef = useRef(cropArea); + const containerRef = useRef<{ width: number; height: number }>({ width: 100, height: 100 }); + + const setCropArea = useCallback( + (area: CropArea) => { + setCropAreaState(getAspectRatioAdjustedCrop(area)); + }, + [getAspectRatioAdjustedCrop] + ); + + const resetCrop = useCallback(() => { + setCropAreaState(getAspectRatioAdjustedCrop(initialCrop)); + }, [initialCrop, getAspectRatioAdjustedCrop]); + + const constrainCrop = useCallback( + (crop: CropArea): CropArea => { + let { x, y, width, height } = crop; + + // Ensure minimum size + width = Math.max(width, minSize); + height = Math.max(height, minSize); + + // Apply aspect ratio if set + if (aspectRatio) { + height = width / aspectRatio; + } + + // Constrain to bounds + x = Math.max(0, Math.min(x, 100 - width)); + y = Math.max(0, Math.min(y, 100 - height)); + width = Math.min(width, 100 - x); + height = Math.min(height, 100 - y); + + return { x, y, width, height }; + }, + [aspectRatio, minSize] + ); + + const handleDragStart = useCallback( + (handle: CropHandle, event: React.MouseEvent | React.TouchEvent) => { + event.preventDefault(); + activeHandleRef.current = handle; + startCropRef.current = { ...cropArea }; + setIsDragging(true); + + const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX; + const clientY = 'touches' in event ? event.touches[0].clientY : event.clientY; + startPosRef.current = { x: clientX, y: clientY }; + + // Get container dimensions from the event target's parent + const target = event.currentTarget as HTMLElement; + const container = target.closest('[data-crop-container]'); + if (container) { + const rect = container.getBoundingClientRect(); + containerRef.current = { width: rect.width, height: rect.height }; + } + }, + [cropArea] + ); + + const handleMove = useCallback( + (event: MouseEvent | TouchEvent) => { + if (!activeHandleRef.current || !isDragging) return; + + const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX; + const clientY = 'touches' in event ? event.touches[0].clientY : event.clientY; + + // Calculate delta as percentage of container + const deltaX = ((clientX - startPosRef.current.x) / containerRef.current.width) * 100; + const deltaY = ((clientY - startPosRef.current.y) / containerRef.current.height) * 100; + + const start = startCropRef.current; + let newCrop: CropArea = { ...start }; + + switch (activeHandleRef.current) { + case 'move': + newCrop = { + ...start, + x: start.x + deltaX, + y: start.y + deltaY, + }; + break; + + case 'nw': + newCrop = { + x: start.x + deltaX, + y: start.y + deltaY, + width: start.width - deltaX, + height: start.height - deltaY, + }; + break; + + case 'n': + newCrop = { + ...start, + y: start.y + deltaY, + height: start.height - deltaY, + }; + break; + + case 'ne': + newCrop = { + ...start, + y: start.y + deltaY, + width: start.width + deltaX, + height: start.height - deltaY, + }; + break; + + case 'e': + newCrop = { + ...start, + width: start.width + deltaX, + }; + break; + + case 'se': + newCrop = { + ...start, + width: start.width + deltaX, + height: start.height + deltaY, + }; + break; + + case 's': + newCrop = { + ...start, + height: start.height + deltaY, + }; + break; + + case 'sw': + newCrop = { + x: start.x + deltaX, + y: start.y, + width: start.width - deltaX, + height: start.height + deltaY, + }; + break; + + case 'w': + newCrop = { + x: start.x + deltaX, + y: start.y, + width: start.width - deltaX, + height: start.height, + }; + break; + } + + setCropAreaState(constrainCrop(newCrop)); + }, + [isDragging, constrainCrop] + ); + + const handleDragEnd = useCallback(() => { + activeHandleRef.current = null; + setIsDragging(false); + }, []); + + // Add global event listeners for drag + useEffect(() => { + if (isDragging) { + window.addEventListener('mousemove', handleMove); + window.addEventListener('mouseup', handleDragEnd); + window.addEventListener('touchmove', handleMove, { passive: false }); + window.addEventListener('touchend', handleDragEnd); + } + + return () => { + window.removeEventListener('mousemove', handleMove); + window.removeEventListener('mouseup', handleDragEnd); + window.removeEventListener('touchmove', handleMove); + window.removeEventListener('touchend', handleDragEnd); + }; + }, [isDragging, handleMove, handleDragEnd]); + + const executeCrop = useCallback( + async (imageSrc: string, mimeType: string = 'image/jpeg'): Promise => { + return new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = 'anonymous'; + + img.onload = () => { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + if (!ctx) { + reject(new Error('Failed to get canvas context')); + return; + } + + // Calculate actual pixel values from percentages + const cropX = (cropArea.x / 100) * img.width; + const cropY = (cropArea.y / 100) * img.height; + const cropWidth = (cropArea.width / 100) * img.width; + const cropHeight = (cropArea.height / 100) * img.height; + + canvas.width = cropWidth; + canvas.height = cropHeight; + + ctx.drawImage( + img, + cropX, + cropY, + cropWidth, + cropHeight, + 0, + 0, + cropWidth, + cropHeight + ); + + canvas.toBlob( + (blob) => { + if (blob) { + resolve(blob); + } else { + reject(new Error('Failed to create blob from canvas')); + } + }, + mimeType, + 0.92 + ); + }; + + img.onerror = () => { + reject(new Error('Failed to load image for cropping')); + }; + + img.src = imageSrc; + }); + }, + [cropArea] + ); + + return { + cropArea, + isDragging, + setCropArea, + resetCrop, + executeCrop, + handleDragStart, + handleMove, + handleDragEnd, + }; +} + +export default useImageCrop; -- 2.49.1