react-native-expo-cropper
Version:
Recadrage polygonal d'images.
609 lines (560 loc) • 19.4 kB
JavaScript
import React, { useState, useRef, useEffect } from 'react';
import {
StyleSheet,
Text,
View,
TouchableOpacity,
Alert,
SafeAreaView,
Platform,
ActivityIndicator,
useWindowDimensions,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Camera, CameraView } from 'expo-camera';
import { Ionicons } from '@expo/vector-icons';
// Max width for camera preview on large screens (tablets) so it doesn't stretch full width
const CAMERA_PREVIEW_MAX_WIDTH = 500;
export default function CustomCamera({
onPhotoCaptured,
onCancel,
instructionText = 'Place the tray inside the green box',
}) {
const { width: windowWidth } = useWindowDimensions();
const cameraPreviewWidth = Math.min(windowWidth, CAMERA_PREVIEW_MAX_WIDTH);
const [isReady, setIsReady] = useState(false);
const [loadingBeforeCapture, setLoadingBeforeCapture] = useState(false);
const [hasPermission, setHasPermission] = useState(null);
const [pictureSize, setPictureSize] = useState(undefined);
const cameraRef = useRef(null);
const cameraWrapperRef = useRef(null);
const insets = useSafeAreaInsets();
const [cameraWrapperLayout, setCameraWrapperLayout] = useState({ width: 0, height: 0, x: 0, y: 0 });
const [greenFrame, setGreenFrame] = useState(null);
const preferredNativeSizes = ['3072x4080', '3024x4032', '3000x4000', '2448x3264'];
const parseSizeString = (size) => {
const match = String(size || '').match(/(\d+)\s*[xX]\s*(\d+)/);
if (!match) return null;
const width = Number(match[1]);
const height = Number(match[2]);
const longEdge = Math.max(width, height);
const shortEdge = Math.min(width, height);
const ratio = longEdge / shortEdge;
return {
label: size,
width,
height,
area: width * height,
normalized: `${shortEdge}x${longEdge}`,
isFourByThree: Math.abs(ratio - (4 / 3)) < 0.04,
};
};
const chooseBestPictureSize = (sizes = []) => {
if (!Array.isArray(sizes) || sizes.length === 0) return undefined;
const parsedSizes = sizes
.map(parseSizeString)
.filter(Boolean);
// 1) Force exact target sizes first (normalized to ignore orientation order)
for (const preferred of preferredNativeSizes) {
const preferredParsed = parseSizeString(preferred);
if (!preferredParsed) continue;
const exact = parsedSizes.find((item) => item.normalized === preferredParsed.normalized);
if (exact) return exact.label;
}
// 2) Prefer high-resolution 4:3 sizes (phone still-photo native aspect)
// Guard against tiny legacy 4:3 modes (e.g. 640x480).
const MIN_HIGH_RES_AREA = 2_000_000; // ~2MP floor
const highResFourByThree = parsedSizes
.filter((item) => item.isFourByThree && item.area >= MIN_HIGH_RES_AREA)
.sort((a, b) => b.area - a.area);
if (highResFourByThree.length > 0) {
return highResFourByThree[0].label;
}
// 3) Fallback to highest area available.
parsedSizes.sort((a, b) => b.area - a.area);
if (parsedSizes.length > 0) {
return parsedSizes[0].label;
}
// iOS may return quality presets like "High", "Medium", "Low".
const highPreset = sizes.find((size) => String(size).toLowerCase() === 'high');
return highPreset || sizes[0];
};
useEffect(() => {
(async () => {
const { status } = await Camera.requestCameraPermissionsAsync();
setHasPermission(status === 'granted');
})();
}, []);
const requestPermissionAgain = async () => {
try {
const { status } = await Camera.requestCameraPermissionsAsync();
setHasPermission(status === 'granted');
} catch (e) {
setHasPermission(false);
}
};
// Helper function to wait for multiple render cycles (works on iOS)
const waitForRender = (cycles = 5) => {
return new Promise((resolve) => {
let count = 0;
const tick = () => {
count++;
if (count >= cycles) {
resolve();
} else {
setImmediate(tick);
}
};
setImmediate(tick);
});
};
// ✅ CRITICAL FIX: Calculate green frame coordinates relative to camera preview
// The green frame should be calculated on the wrapper (as it's visually drawn there)
// But we store it with wrapper dimensions so ImageCropper can map it correctly
//
// NOTE: Camera capture aspect ratio (typically 4:3) may differ from wrapper aspect ratio (9:16)
// This is handled in ImageCropper by using "cover" mode to match preview content
const calculateGreenFrameCoordinates = () => {
const wrapperWidth = cameraWrapperLayout.width;
const wrapperHeight = cameraWrapperLayout.height;
if (wrapperWidth === 0 || wrapperHeight === 0) {
console.warn("Camera wrapper layout not ready, cannot calculate green frame");
return null;
}
// ✅ Calculate green frame as percentage of WRAPPER
const frameWidth = wrapperWidth * 0.85; // 85% of wrapper width
const frameHeight = wrapperHeight * 0.70; // 70% of wrapper height
const frameX = (wrapperWidth - frameWidth) / 2; // Centered horizontally
const frameY = (wrapperHeight - frameHeight) / 2; // Centered vertically
const frameCoords = {
x: frameX,
y: frameY,
width: frameWidth,
height: frameHeight,
wrapperWidth,
wrapperHeight,
// ✅ Store percentages for easier mapping later
percentX: (frameX / wrapperWidth) * 100,
percentY: (frameY / wrapperHeight) * 100,
percentWidth: 85, // 85% of wrapper width
percentHeight: 70 // 70% of wrapper height
};
console.log("✅ Green frame coordinates calculated:", frameCoords);
return frameCoords;
};
// 🔁 Keep green frame state in sync with wrapper layout so we can both render it
// and send the exact same coordinates along with the captured photo.
useEffect(() => {
const coords = calculateGreenFrameCoordinates();
if (coords) {
setGreenFrame(coords);
}
}, [cameraWrapperLayout]);
const takePicture = async () => {
if (!hasPermission) {
Alert.alert(
"Permission requise",
"Veuillez autoriser l'accès à la caméra pour prendre une photo."
);
return;
}
if (cameraRef.current) {
try {
// Show loading after a delay (using setImmediate for iOS compatibility)
waitForRender(5).then(() => {
setLoadingBeforeCapture(true);
});
// Wait a bit before taking picture (works on iOS)
await waitForRender(2);
// ✅ OPTIMIZED: Capture with maximum quality and native camera ratio
// Platform-specific optimizations for best quality
const captureOptions = {
// Maximum quality (0-1, 1 = best quality, no compression)
quality: 1,
// Disable shutter sound for better UX
shutterSound: false,
// ✅ CRITICAL: skipProcessing preserves original resolution and avoids interpolation
// This ensures pixel-perfect quality and prevents premature resizing
// NOTE: Some Android devices are unstable with skipProcessing; keep it off there.
skipProcessing: Platform.OS === 'ios',
// Include EXIF metadata (orientation, camera settings, etc.)
exif: true,
// ✅ Platform-specific optimizations
...(Platform.OS === 'ios' && {
// iOS: Use native capture format (typically 4:3 or 16:9 depending on device)
// No additional processing to preserve quality
}),
...(Platform.OS === 'android' && {
// Android: Ensure maximum resolution capture
// skipProcessing already handles this, but we can add Android-specific options if needed
}),
};
console.log("📸 Capturing photo with maximum quality settings:", {
platform: Platform.OS,
options: captureOptions,
wrapperSize: { width: cameraWrapperLayout.width, height: cameraWrapperLayout.height }
});
const photo = await cameraRef.current.takePictureAsync(captureOptions);
// ✅ Validate captured photo dimensions
if (!photo.width || !photo.height || photo.width === 0 || photo.height === 0) {
throw new Error("Invalid photo dimensions received from camera");
}
const capturedAspectRatio = photo.width / photo.height;
console.log("✅ Photo captured with maximum quality:", {
uri: photo.uri,
width: photo.width,
height: photo.height,
aspectRatio: capturedAspectRatio.toFixed(3),
expectedRatio: "~1.33 (4:3) or ~1.78 (16:9)",
exif: photo.exif ? "present" : "missing",
fileSize: photo.uri ? "available" : "unknown"
});
// ✅ CRITICAL FIX: Use the same green frame coordinates that are used for rendering
// Fallback to recalculation if, for some reason, state is not yet set
const greenFrameCoords = greenFrame || calculateGreenFrameCoordinates();
if (!greenFrameCoords) {
throw new Error("Green frame coordinates not available");
}
// ✅ Send photo URI and frame data to ImageCropper
// The photo maintains its native resolution and aspect ratio
// ImageCropper will handle display and cropping while preserving quality
onPhotoCaptured(photo.uri, {
greenFrame: greenFrameCoords,
capturedImageSize: {
width: photo.width,
height: photo.height,
aspectRatio: capturedAspectRatio
}
});
setLoadingBeforeCapture(false);
} catch (error) {
console.error("❌ Error capturing photo:", error);
setLoadingBeforeCapture(false);
Alert.alert(
"Erreur",
`Impossible de capturer la photo: ${error.message || "Erreur inconnue"}. Veuillez réessayer.`
);
}
}
};
return (
<SafeAreaView style={styles.outerContainer}>
{hasPermission === null && (
<View style={styles.permissionContainer}>
<ActivityIndicator size="large" color={PRIMARY_GREEN} />
<Text style={styles.permissionText}>Demande d'autorisation caméra...</Text>
</View>
)}
{hasPermission === false && (
<View style={styles.permissionContainer}>
<Text style={styles.permissionText}>
Autorisation caméra refusée. Activez-la dans les paramètres pour continuer.
</Text>
<TouchableOpacity style={styles.permissionButton} onPress={requestPermissionAgain}>
<Text style={styles.permissionButtonText}>Réessayer</Text>
</TouchableOpacity>
</View>
)}
{hasPermission === true && (
<>
<View
style={[styles.cameraWrapper, { width: cameraPreviewWidth }]}
ref={cameraWrapperRef}
onLayout={(e) => {
const layout = e.nativeEvent.layout;
setCameraWrapperLayout({
width: layout.width,
height: layout.height,
x: layout.x,
y: layout.y
});
console.log("Camera wrapper layout updated:", layout);
}}
>
<CameraView
style={styles.camera}
facing="back"
ref={cameraRef}
pictureSize={pictureSize}
onCameraReady={async () => {
try {
setIsReady(false);
const sizes = await cameraRef.current?.getAvailablePictureSizesAsync?.();
const bestSize = chooseBestPictureSize(sizes || []);
if (bestSize) {
setPictureSize(bestSize);
console.log("✅ Selected highest available picture size:", {
preferredNativeSizes,
bestSize,
availableSizes: sizes,
});
} else {
console.log("ℹ️ No explicit picture size selected; using camera default");
}
} catch (sizeError) {
console.warn("⚠️ Could not query picture sizes; using camera default:", sizeError);
} finally {
// Give CameraView a moment to apply pictureSize before enabling capture.
await waitForRender(2);
setIsReady(true);
console.log("✅ Camera ready - Maximum quality capture enabled");
}
}}
// ✅ Ensure camera uses native aspect ratio (typically 4:3 for most devices)
// The wrapper constrains the view, but capture uses full sensor resolution
// This ensures preview matches what will be captured
/>
<View style={[styles.topBar, styles.topBarCentered]}>
<Text style={[styles.topBarTitle, styles.topBarCenteredTitle]}>{instructionText}</Text>
</View>
{/* Loading overlay */}
{loadingBeforeCapture && (
<>
<View style={styles.loadingOverlay}>
<ActivityIndicator size="large" color={PRIMARY_GREEN} />
</View>
<View style={styles.touchBlocker} pointerEvents="auto" />
</>
)}
{/* Cadre de scan - rendered using calculated coordinates (x, y, width, height) */}
{greenFrame && (
<View
style={[
styles.scanFrame,
{
left: greenFrame.x,
top: greenFrame.y,
width: greenFrame.width,
height: greenFrame.height,
},
]}
/>
)}
</View>
<View style={[styles.buttonContainer, { bottom: (insets?.bottom || 0) + 12, marginBottom: 0 }]}>
<TouchableOpacity
style={[styles.captureButton, (!isReady || loadingBeforeCapture || !hasPermission) && { opacity: 0.55 }]}
onPress={takePicture}
disabled={!isReady || loadingBeforeCapture || !hasPermission}
>
<Ionicons name="camera-outline" size={26} color="#121212" />
</TouchableOpacity>
</View>
</>
)}
</SafeAreaView>
);
}
const PRIMARY_GREEN = '#689F38';
const ACCENT_GREEN = '#689F38';
const DEEP_BLACK = '#060708';
const GLOW_WHITE = 'rgba(255, 255, 255, 0.9)';
const GLASS = 'rgba(20,22,26,0.9)';
const styles = StyleSheet.create({
outerContainer: {
flex: 1,
backgroundColor: DEEP_BLACK,
justifyContent: 'center',
alignItems: 'center',
},
permissionContainer: {
flex: 1,
width: '100%',
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 24,
},
permissionText: {
marginTop: 12,
fontSize: 14,
color: GLOW_WHITE,
textAlign: 'center',
},
permissionButton: {
marginTop: 16,
backgroundColor: GLASS,
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 12,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.12)',
},
permissionButtonText: {
color: 'white',
fontWeight: '600',
},
cameraWrapper: {
aspectRatio: 9 / 16,
borderRadius: 0,
overflow: 'hidden',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
},
cameraInstruction: {
position: 'absolute',
bottom: 170,
left: 18,
right: 18,
textAlign: 'center',
color: 'rgba(255,255,255,0.82)',
fontSize: 13,
fontWeight: '500',
zIndex: 10,
textShadowColor: 'rgba(0, 0, 0, 0.6)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
pointerEvents: 'none',
},
camera: {
...StyleSheet.absoluteFillObject,
},
scanFrame: {
position: 'absolute',
borderWidth: 2,
borderColor: ACCENT_GREEN,
borderRadius: 2,
backgroundColor: 'rgba(0, 0, 0, 0.1)',
},
loadingOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
zIndex: 20,
justifyContent: 'center',
alignItems: 'center',
},
touchBlocker: {
...StyleSheet.absoluteFillObject,
zIndex: 21,
backgroundColor: 'transparent',
},
buttonContainer: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
marginBottom: 8,
alignItems: 'center',
justifyContent: 'center',
},
captureButton: {
marginTop: 10,
width: 68,
height: 68,
borderRadius: 20,
backgroundColor: ACCENT_GREEN,
alignItems: 'center',
justifyContent: 'center',
shadowColor: ACCENT_GREEN,
shadowOpacity: 0.42,
shadowRadius: 10,
shadowOffset: { width: 0, height: 2 },
elevation: 8,
},
topBar: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 20,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 18,
},
topBarCentered: {
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 0,
},
topIconButton: {
width: 36,
height: 36,
borderRadius: 12,
backgroundColor: 'rgba(0,0,0,0.35)',
alignItems: 'center',
justifyContent: 'center',
},
topBarTitle: {
color: ACCENT_GREEN,
fontSize: 30,
fontWeight: '700',
letterSpacing: 0.3,
},
topBarCenteredTitle: {
width: '100%',
textAlign: 'center',
paddingHorizontal: 18,
},
detectBadge: {
position: 'absolute',
top: 76,
left: 18,
zIndex: 20,
backgroundColor: 'rgba(40, 44, 50, 0.82)',
borderRadius: 16,
paddingHorizontal: 14,
paddingVertical: 10,
},
detectBadgeText: {
color: 'rgba(255,255,255,0.78)',
fontSize: 12,
fontWeight: '700',
letterSpacing: 1.6,
textTransform: 'uppercase',
},
cornerHandle: {
position: 'absolute',
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: '#DFFFD0',
borderWidth: 2,
borderColor: ACCENT_GREEN,
shadowColor: ACCENT_GREEN,
shadowOpacity: 0.55,
shadowRadius: 10,
shadowOffset: { width: 0, height: 0 },
elevation: 8,
zIndex: 25,
},
dockLabel: {
marginTop: 10,
marginBottom: 10,
color: 'rgba(255,255,255,0.63)',
textAlign: 'center',
fontSize: 12,
letterSpacing: 3.2,
textTransform: 'uppercase',
},
dock: {
height: 76,
width: '92%',
borderRadius: 22,
backgroundColor: 'rgba(27,30,35,0.92)',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.08)',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-around',
paddingHorizontal: 12,
},
dockButton: {
width: 44,
height: 44,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
dockButtonActive: {
width: 74,
height: 52,
borderRadius: 16,
backgroundColor: ACCENT_GREEN,
shadowColor: ACCENT_GREEN,
shadowOpacity: 0.4,
shadowRadius: 10,
shadowOffset: { width: 0, height: 1 },
elevation: 8,
},
});