react-native-expo-cropper
Version:
Recadrage polygonal d'images.
1,158 lines (1,028 loc) • 63.8 kB
JavaScript
import styles from './ImageCropperStyles';
import React, { useState, useRef, useEffect } from 'react';
import { Modal, View, Image, TouchableOpacity, Text, Platform, PixelRatio, StyleSheet, ActivityIndicator, useWindowDimensions } from 'react-native';
import Svg, { Path, Circle } from 'react-native-svg';
import CustomCamera from './CustomCamera';
import * as ImageManipulator from 'expo-image-manipulator';
import * as ImagePicker from 'expo-image-picker';
import { Ionicons , AntDesign } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { applyMaskToImage, MaskView } from './ImageMaskProcessor';
const PRIMARY_GREEN = '#689F38';
const ACCENT_GREEN = '#689F38';
const TUTORIAL_IMAGE_MAP = {
seedcount: require('./assets/preview-pic.png'),
trackseeds: require('./assets/preview-trackseeds.png'),
fruitcheck: require('./assets/preview-fruitcheck.png'),
};
const TUTORIAL_IMAGE_ALIASES = {
seedcount: 'seedcount',
'seed-count': 'seedcount',
trackseeds: 'trackseeds',
'track-seeds': 'trackseeds',
fruitcheck: 'fruitcheck',
'fruit-check': 'fruitcheck',
};
// Base dimension for responsive scaling (e.g. ~375pt design width)
const RESPONSIVE_BASE = 375;
const ImageCropper = ({
onConfirm,
openCameraFirst,
initialImage,
addheight,
rotationLabel,
onCancel,
cancelText = 'Cancel',
confirmText = 'Use Scan',
resetText = 'Enhance',
rotateText = 'Rotate',
cameraInstructionText = 'Place the tray inside the green box',
tutorialSkipText = 'Skip',
tutorialContinueText = 'Continue',
tutorial = '',
onSkipTutorial,
showTutorial = true,
}) => {
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
// Responsive scale: image and UI adapt to small (phone) and large (tablet) screens
const responsiveScale = Math.max(0.65, Math.min(2, Math.min(windowWidth, windowHeight) / RESPONSIVE_BASE));
const normalizedTutorial = String(tutorial || '').trim().toLowerCase();
const tutorialKey = TUTORIAL_IMAGE_ALIASES[normalizedTutorial] || normalizedTutorial.replace(/[^a-z0-9]/g, '');
const tutorialImageSource = tutorialKey ? TUTORIAL_IMAGE_MAP[tutorialKey] : null;
const guessExtensionFromUri = (uri) => {
if (!uri || typeof uri !== 'string') return null;
const match = uri.match(/\.([a-zA-Z0-9]+)(?:[?#].*)?$/);
if (!match) return null;
const ext = match[1].toLowerCase();
// Keep only common image extensions
if (['jpg', 'jpeg', 'png', 'webp', 'heic', 'heif'].includes(ext)) return ext;
return null;
};
const normalizeGalleryDimensions = (imgWidth, imgHeight, uri) => {
let width = imgWidth;
let height = imgHeight;
// Some Android gallery/ImagePicker URIs report density-scaled dimensions (e.g. 1512x2016
// instead of 3024x4032 on a 2x screen). Correct to physical pixels when it is likely safe.
if (Platform.OS === 'android' && uri && typeof uri === 'string') {
const pixelRatio = PixelRatio.get();
const roundedRatio = Math.max(1, Math.round(pixelRatio));
const isLikelyImagePickerCacheUri =
uri.includes('/cache/ImagePicker/') || uri.includes('/ImagePicker/');
const isPotentiallyScaled = roundedRatio > 1 && Math.max(width, height) <= 2200;
if (isLikelyImagePickerCacheUri && isPotentiallyScaled) {
const scaledWidth = Math.round(width * roundedRatio);
const scaledHeight = Math.round(height * roundedRatio);
// Keep safety bounds to avoid incorrect inflation on genuinely small images.
if (
scaledWidth >= width &&
scaledHeight >= height &&
scaledWidth <= 12000 &&
scaledHeight <= 12000
) {
width = scaledWidth;
height = scaledHeight;
console.log("✅ Corrected Android gallery dimensions to physical pixels:", {
original: { width: imgWidth, height: imgHeight },
corrected: { width, height },
pixelRatio,
uri,
});
}
}
}
return { width, height };
};
const [image, setImage] = useState(null);
const [points, setPoints] = useState([]);
const [showResult, setShowResult] = useState(false);
const [showCustomCamera, setShowCustomCamera] = useState(false);
const [showCameraTutorial, setShowCameraTutorial] = useState(false);
const viewRef = useRef(null);
const maskViewRef = useRef(null); // Ref pour la vue de masque (invisible)
const sourceImageUri = useRef(null); // keep original image URI (full-res) for upload
const cameraFrameData = useRef(null); // ✅ Store green frame coordinates from camera
// ✅ RÉFÉRENTIEL UNIQUE : Wrapper commun 9/16 (identique à CustomCamera)
// Ce wrapper est utilisé dans CustomCamera ET ImageCropper pour garantir pixel-perfect matching
const commonWrapperRef = useRef(null);
const commonWrapperLayout = useRef({ x: 0, y: 0, width: 0, height: 0 });
// ✅ REFACTORISATION : Séparation claire entre dimensions originales et affichage
// Dimensions réelles de l'image originale (pixels)
const originalImageDimensions = useRef({ width: 0, height: 0 });
// Dimensions et position d'affichage à l'écran (pour le calcul des points de crop)
const displayedImageLayout = useRef({ x: 0, y: 0, width: 0, height: 0 });
// Conserver imageMeasure pour compatibilité avec le code existant (utilisé pour SVG overlay)
const imageMeasure = useRef({ x: 0, y: 0, width: 0, height: 0 });
// ✅ imageDisplayRect : Rectangle réel de l'image affichée (quand resizeMode='contain') à l'intérieur du wrapper commun
// C'est la zone où l'image est réellement visible (avec letterboxing si nécessaire)
// Les points de crop DOIVENT rester dans cette zone pour éviter de cropper hors de l'image
const imageDisplayRect = useRef({ x: 0, y: 0, width: 0, height: 0 });
// ✅ COMPATIBILITÉ : displayedContentRect reste pour le code existant, mais pointe vers imageDisplayRect
const displayedContentRect = imageDisplayRect;
// ✅ RÉFÉRENTIEL UNIQUE : Calculer imageDisplayRect à l'intérieur du wrapper commun
// - CAMERA: use "cover" mode → image fills wrapper, imageDisplayRect = full wrapper (same as preview)
// - GALLERY: use "contain" mode → imageDisplayRect = letterboxed area
const updateImageDisplayRect = (wrapperWidth, wrapperHeight) => {
const iw = originalImageDimensions.current.width;
const ih = originalImageDimensions.current.height;
// ✅ CAMERA IMAGE: Use full wrapper so green frame and white frame show same content
if (cameraFrameData.current && cameraFrameData.current.greenFrame && wrapperWidth > 0 && wrapperHeight > 0) {
imageDisplayRect.current = { x: 0, y: 0, width: wrapperWidth, height: wrapperHeight };
console.log("✅ Image display rect (COVER mode for camera - full wrapper):", imageDisplayRect.current);
return;
}
console.log("🔄 updateImageDisplayRect called:", {
originalDimensions: { width: iw, height: ih },
wrapperDimensions: { width: wrapperWidth, height: wrapperHeight }
});
if (iw > 0 && ih > 0 && wrapperWidth > 0 && wrapperHeight > 0) {
// Calculer comment l'image s'affiche en "contain" dans le wrapper (gallery)
const scale = Math.min(wrapperWidth / iw, wrapperHeight / ih);
const imageDisplayWidth = iw * scale;
const imageDisplayHeight = ih * scale;
const offsetX = (wrapperWidth - imageDisplayWidth) / 2;
const offsetY = (wrapperHeight - imageDisplayHeight) / 2;
imageDisplayRect.current = {
x: offsetX,
y: offsetY,
width: imageDisplayWidth,
height: imageDisplayHeight,
};
console.log("✅ Image display rect (contain in wrapper) calculated:", {
wrapper: { width: wrapperWidth, height: wrapperHeight },
imageDisplayRect: imageDisplayRect.current,
scale: scale.toFixed(4)
});
return;
}
if (wrapperWidth > 0 && wrapperHeight > 0) {
imageDisplayRect.current = { x: 0, y: 0, width: wrapperWidth, height: wrapperHeight };
console.log("⚠️ Using wrapper dimensions as fallback (original dimensions not available yet):", imageDisplayRect.current);
} else {
imageDisplayRect.current = { x: 0, y: 0, width: 0, height: 0 };
console.warn("❌ Cannot calculate imageDisplayRect: missing dimensions");
}
};
// ✅ COMPATIBILITÉ : Alias pour le code existant
const updateDisplayedContentRect = updateImageDisplayRect;
const selectedPointIndex = useRef(null);
const lastTap = useRef(null);
// ✅ CRITICAL: Guard to prevent double crop box initialization
// This ensures crop box is initialized only once, especially for camera images
const hasInitializedCropBox = useRef(false);
const imageSource = useRef(null); // 'camera' | 'gallery' | null
// ✅ FREE DRAG: Store initial touch position and point position for delta-based movement
const initialTouchPosition = useRef(null); // { x, y } - initial touch position when drag starts
const initialPointPosition = useRef(null); // { x, y } - initial point position when drag starts
const lastTouchPosition = useRef(null); // { x, y } - last touch position for incremental delta calculation
// ✅ CRITICAL: dragBase stores the VISUAL position (can be overshoot) during drag
// This ensures smooth continuous drag without "dead zones" at boundaries
// dragBase is updated with applied position (overshoot) after each movement
// and is used as base for next delta calculation
const dragBase = useRef(null); // { x, y } - visual position during drag (can be overshoot)
// ✅ NEW APPROACH: touchOffset stores the initial offset between point and touch
// This eliminates delta accumulation issues and "dead zones"
// Once set at drag start, it remains constant throughout the drag
const touchOffset = useRef(null); // { x, y } - offset = pointPosition - touchPosition
// ✅ Track if point was clamped in previous frame (to detect transition)
const wasClampedLastFrame = useRef({ x: false, y: false });
// Angle de rotation accumulé (pour éviter les rotations multiples)
const rotationAngle = useRef(0);
// États pour la vue de masque temporaire
const [maskImageUri, setMaskImageUri] = useState(null);
const [maskPoints, setMaskPoints] = useState([]);
const [maskDimensions, setMaskDimensions] = useState({ width: 0, height: 0 });
const [showMaskView, setShowMaskView] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [showFullScreenCapture, setShowFullScreenCapture] = useState(false);
const [isRotating, setIsRotating] = useState(false);
const cameraLaunchInProgress = useRef(false);
const rotationInProgressRef = useRef(false); // block duplicate taps immediately
const lastValidPosition = useRef(null);
const insets = useSafeAreaInsets();
// ✅ NEW ARCH: mobile does NOT export the final crop.
// No view-shot / captureRef / bitmap masking on device.
const enableMask = false;
const enableRotation = true; // rotation resets crop box so it is re-initialized on rotated image.
const launchNativeCamera = async () => {
if (cameraLaunchInProgress.current) return;
cameraLaunchInProgress.current = true;
setShowCameraTutorial(false);
try {
const permission = await ImagePicker.requestCameraPermissionsAsync();
if (!permission.granted) {
console.warn("Camera permission denied for launchCameraAsync");
alert("Camera permission denied. Please allow camera access.");
setShowCameraTutorial(showTutorial);
return;
}
const result = await ImagePicker.launchCameraAsync({
mediaTypes: ['images'],
allowsEditing: false,
quality: 1,
exif: true,
});
if (result.canceled || !result.assets || result.assets.length === 0) {
console.log("Native camera canceled by user");
setShowCameraTutorial(showTutorial);
return;
}
const capturedAsset = result.assets[0];
if (!capturedAsset?.uri) {
throw new Error("No image URI returned by launchCameraAsync");
}
// Native camera app capture should be treated like gallery/image input in cropper.
cameraFrameData.current = null;
imageSource.current = 'nativeCamera';
hasInitializedCropBox.current = false;
sourceImageUri.current = capturedAsset.uri;
rotationAngle.current = 0;
setPoints([]);
if (capturedAsset.width && capturedAsset.height) {
originalImageDimensions.current = {
width: capturedAsset.width,
height: capturedAsset.height,
};
}
setImage(capturedAsset.uri);
setShowCustomCamera(false);
console.log("✅ Native camera image selected:", {
width: capturedAsset.width,
height: capturedAsset.height,
uri: capturedAsset.uri,
});
} catch (error) {
console.error("Error launching native camera:", error);
alert(`Impossible d'ouvrir la caméra native: ${error?.message || 'Erreur inconnue'}`);
setShowCameraTutorial(showTutorial);
} finally {
cameraLaunchInProgress.current = false;
}
};
const handleSkipTutorial = () => {
if (typeof onSkipTutorial === 'function') {
onSkipTutorial(false);
}
setShowCameraTutorial(false);
launchNativeCamera();
};
useEffect(() => {
if (openCameraFirst) {
setShowCustomCamera(false);
if (showTutorial) {
setShowCameraTutorial(true);
} else {
setShowCameraTutorial(false);
launchNativeCamera();
}
return;
}
if (initialImage) {
setImage(initialImage);
sourceImageUri.current = initialImage;
// ✅ CRITICAL: Reset points when loading a new image from gallery
// This ensures the crop box will be automatically initialized
setPoints([]);
rotationAngle.current = 0;
// Clear camera frame data for gallery images
cameraFrameData.current = null;
// ✅ CRITICAL: Reset initialization guard for new image
hasInitializedCropBox.current = false;
imageSource.current = null;
setShowCameraTutorial(false);
}
}, [openCameraFirst, initialImage, showTutorial]);
// ✅ REFACTORISATION : Stocker uniquement les dimensions originales (pas de calcul théorique)
// ✅ CRITICAL FIX: Single source of truth for image dimensions
useEffect(() => {
if (!image) {
originalImageDimensions.current = { width: 0, height: 0 };
hasInitializedCropBox.current = false;
imageSource.current = null;
return;
}
if (!sourceImageUri.current) {
sourceImageUri.current = image;
}
// ✅ Preserve dimensions returned by launchCameraAsync.
// On some Android devices, Image.getSize() for this URI can return half-resolution values.
if (
imageSource.current === 'nativeCamera' &&
originalImageDimensions.current.width > 0 &&
originalImageDimensions.current.height > 0
) {
const { width: nativeWidth, height: nativeHeight } = originalImageDimensions.current;
hasInitializedCropBox.current = false;
const wrapper = commonWrapperLayout.current;
if (wrapper.width > 0 && wrapper.height > 0) {
updateImageDisplayRect(wrapper.width, wrapper.height);
}
console.log("✅ Using native camera dimensions from launchCameraAsync (skip Image.getSize):", {
width: nativeWidth,
height: nativeHeight,
source: 'launchCameraAsync',
});
return;
}
// ✅ CRITICAL FIX #1: If we have capturedImageSize from camera, use it as SINGLE SOURCE OF TRUTH
// takePictureAsync returns physical dimensions, while Image.getSize() may return EXIF-oriented dimensions
// DO NOT call Image.getSize() for camera images - it can return swapped dimensions on Android
if (cameraFrameData.current && cameraFrameData.current.capturedImageSize) {
const { width: capturedWidth, height: capturedHeight } = cameraFrameData.current.capturedImageSize;
originalImageDimensions.current = {
width: capturedWidth,
height: capturedHeight,
};
imageSource.current = 'camera';
hasInitializedCropBox.current = false; // Reset guard for new camera image
console.log("✅ Using captured image dimensions from takePictureAsync (SINGLE SOURCE OF TRUTH):", {
width: capturedWidth,
height: capturedHeight,
source: 'takePictureAsync',
note: 'Image.getSize() will NOT be called for camera images'
});
// ✅ CRITICAL: Recalculate imageDisplayRect immediately with camera dimensions
const wrapper = commonWrapperLayout.current;
if (wrapper.width > 0 && wrapper.height > 0) {
updateImageDisplayRect(wrapper.width, wrapper.height);
// ✅ CRITICAL FIX #2: Initialize crop box immediately when cameraFrameData is available
// This ensures camera images are initialized from greenFrame BEFORE any other initialization
if (cameraFrameData.current && cameraFrameData.current.greenFrame && !hasInitializedCropBox.current) {
console.log("✅ Initializing crop box from cameraFrameData (immediate in useEffect):", {
hasGreenFrame: !!cameraFrameData.current.greenFrame,
wrapper: wrapper,
originalDimensions: originalImageDimensions.current
});
initializeCropBox();
}
}
return; // ✅ CRITICAL: Exit early - DO NOT call Image.getSize()
}
// ✅ FALLBACK: Use Image.getSize() ONLY for gallery images (no cameraFrameData)
// BUT: Check again right before calling to avoid race condition
if (cameraFrameData.current && cameraFrameData.current.capturedImageSize) {
console.log("⚠️ cameraFrameData exists, skipping Image.getSize() call");
return;
}
// ✅ CRITICAL: Also check imageSource - if it's 'camera', don't call Image.getSize()
if (imageSource.current === 'camera') {
console.log("⚠️ imageSource is 'camera', skipping Image.getSize() call");
return;
}
// ✅ CRITICAL: Set imageSource to 'gallery' ONLY if we're sure it's not a camera image
// Don't set it yet - we'll set it in the callback after verifying
hasInitializedCropBox.current = false; // Reset guard for new image
Image.getSize(image, (imgWidth, imgHeight) => {
// ✅ CRITICAL SAFETY #1: Check if cameraFrameData appeared while Image.getSize() was resolving
// This is the PRIMARY check - cameraFrameData takes precedence
if (cameraFrameData.current && cameraFrameData.current.capturedImageSize) {
console.warn("⚠️ Image.getSize() resolved but cameraFrameData exists - IGNORING Image.getSize() result to prevent dimension swap");
console.warn("⚠️ Camera dimensions (correct):", cameraFrameData.current.capturedImageSize);
console.warn("⚠️ Image.getSize() dimensions (potentially swapped):", { width: imgWidth, height: imgHeight });
return; // ✅ CRITICAL: Exit early - do NOT update dimensions or initialize crop box
}
// ✅ CRITICAL SAFETY #2: Check imageSource (should be 'camera' if cameraFrameData was set)
if (imageSource.current === 'camera') {
console.warn("⚠️ Image.getSize() resolved but imageSource is 'camera' - IGNORING Image.getSize() result");
console.warn("⚠️ Image.getSize() dimensions (potentially swapped):", { width: imgWidth, height: imgHeight });
return; // ✅ CRITICAL: Exit early - do NOT update dimensions or initialize crop box
}
// ✅ CRITICAL SAFETY #3: Check if crop box was already initialized (from camera)
if (hasInitializedCropBox.current) {
console.warn("⚠️ Image.getSize() resolved but crop box already initialized - IGNORING result to prevent double initialization");
return;
}
// ✅ SAFE: This is a gallery image, proceed with Image.getSize() result
imageSource.current = 'gallery';
const normalizedDims = normalizeGalleryDimensions(imgWidth, imgHeight, image);
originalImageDimensions.current = {
width: normalizedDims.width,
height: normalizedDims.height,
};
console.log("✅ Image dimensions from Image.getSize() (gallery image):", {
width: normalizedDims.width,
height: normalizedDims.height,
rawWidth: imgWidth,
rawHeight: imgHeight,
platform: Platform.OS,
pixelRatio: PixelRatio.get(),
uri: image,
source: 'Image.getSize()'
});
// ✅ RÉFÉRENTIEL UNIQUE : Recalculer imageDisplayRect dans le wrapper commun
// dès qu'on connaît la taille originale de l'image
const wrapper = commonWrapperLayout.current;
if (wrapper.width > 0 && wrapper.height > 0) {
updateImageDisplayRect(wrapper.width, wrapper.height);
// ✅ IMPORTANT: pour les images de la galerie (pas de cameraFrameData),
// initialiser automatiquement le cadre blanc (70% du wrapper) une fois que
// nous connaissons à la fois le wrapper et les dimensions originales.
// ✅ CRITICAL: Guard against double initialization
if (!hasInitializedCropBox.current && points.length === 0 && imageSource.current === 'gallery') {
initializeCropBox();
hasInitializedCropBox.current = true;
}
}
}, (error) => {
console.error("Error getting image size:", error);
});
}, [image]);
// Le cadre blanc doit être calculé sur le MÊME wrapper que le cadre vert (9/16)
// Ensuite, on restreint les points pour qu'ils restent dans imageDisplayRect (image visible)
const initializeCropBox = () => {
// ✅ CRITICAL FIX #2: Guard against double initialization
if (hasInitializedCropBox.current) {
console.log("⚠️ Crop box already initialized, skipping duplicate initialization");
return;
}
// ✅ CRITICAL: Ensure common wrapper layout is available
const wrapper = commonWrapperLayout.current;
if (wrapper.width === 0 || wrapper.height === 0) {
console.warn("Cannot initialize crop box: common wrapper layout not ready");
return;
}
// ✅ CRITICAL: Ensure imageDisplayRect is available (zone réelle de l'image dans le wrapper)
let imageRect = imageDisplayRect.current;
if (imageRect.width === 0 || imageRect.height === 0) {
// Recalculer si nécessaire
if (originalImageDimensions.current.width > 0 && originalImageDimensions.current.height > 0) {
updateImageDisplayRect(wrapper.width, wrapper.height);
imageRect = imageDisplayRect.current;
} else {
console.warn("Cannot initialize crop box: imageDisplayRect not available (original dimensions missing)");
return;
}
}
// ✅ CRITICAL FIX: Calculate crop box as percentage of VISIBLE IMAGE AREA (imageDisplayRect)
// in WRAPPER-RELATIVE coordinates (same space as points/touches and SVG).
const imageRectX = imageRect.x;
const imageRectY = imageRect.y;
// ✅ DEFAULT: Crop box (70% of VISIBLE IMAGE AREA - centered)
const boxWidth = imageRect.width * 0.70; // 70% of visible image width
const boxHeight = imageRect.height * 0.70; // 70% of visible image height
const boxX = imageRectX + (imageRect.width - boxWidth) / 2; // Centered in image area
const boxY = imageRectY + (imageRect.height - boxHeight) / 2; // Centered in image area
// ✅ SAFETY: Validate calculated coordinates before creating points
const isValidCoordinate = (val) => typeof val === 'number' && isFinite(val) && !isNaN(val);
if (!isValidCoordinate(boxX) || !isValidCoordinate(boxY) ||
!isValidCoordinate(boxWidth) || !isValidCoordinate(boxHeight)) {
console.warn("⚠️ Invalid coordinates calculated for default crop box, skipping initialization");
return;
}
const newPoints = [
{ x: boxX, y: boxY },
{ x: boxX + boxWidth, y: boxY },
{ x: boxX + boxWidth, y: boxY + boxHeight },
{ x: boxX, y: boxY + boxHeight },
];
// ✅ SAFETY: Validate all points before setting
const validPoints = newPoints.filter(p => isValidCoordinate(p.x) && isValidCoordinate(p.y));
if (validPoints.length !== newPoints.length) {
console.warn("⚠️ Some points have invalid coordinates in default crop box, skipping initialization");
return;
}
console.log("✅ Initializing crop box (default - 70% of visible image area, gallery only):", {
wrapper: { width: wrapper.width, height: wrapper.height },
imageDisplayRect: imageRect,
boxInWrapperCoords: { x: boxX, y: boxY, width: boxWidth, height: boxHeight },
points: newPoints,
});
setPoints(newPoints);
hasInitializedCropBox.current = true; // ✅ CRITICAL: Mark as initialized
};
// ✅ RÉFÉRENTIEL UNIQUE : Callback pour mettre à jour le layout du wrapper commun
// Ce wrapper a exactement les mêmes dimensions que le wrapper de CustomCamera (9/16, width = screenWidth)
const onCommonWrapperLayout = (e) => {
const layout = e.nativeEvent.layout;
commonWrapperLayout.current = {
x: layout.x,
y: layout.y,
width: layout.width,
height: layout.height
};
console.log("✅ Common wrapper layout updated:", commonWrapperLayout.current);
// ✅ Recalculer imageDisplayRect dès que le wrapper est prêt
if (originalImageDimensions.current.width > 0 && originalImageDimensions.current.height > 0) {
updateImageDisplayRect(layout.width, layout.height);
// ✅ CRITICAL FIX #2: Initialize crop box ONLY if not already initialized
// For camera images: initialize ONLY from greenFrame (already done when cameraFrameData was set)
if (!hasInitializedCropBox.current && points.length === 0) {
// ✅ CRITICAL: Only initialize for gallery images here
// Camera images should be initialized when cameraFrameData is set, not here
if (imageSource.current !== 'camera') {
initializeCropBox();
}
}
}
};
// ✅ REFACTORISATION : Mettre à jour les dimensions d'affichage et les dimensions pour SVG
const onImageLayout = (e) => {
const layout = e.nativeEvent.layout;
// Stocker les dimensions d'affichage réelles (pour conversion de coordonnées)
displayedImageLayout.current = {
x: layout.x,
y: layout.y,
width: layout.width,
height: layout.height
};
// Conserver aussi dans imageMeasure pour compatibilité avec SVG overlay
imageMeasure.current = {
x: layout.x,
y: layout.y,
width: layout.width,
height: layout.height
};
// ✅ Si l'image vient de la caméra et que les dimensions originales ne sont pas encore définies,
// les initialiser à partir de cameraFrameData AVANT de calculer le contentRect.
if (
originalImageDimensions.current.width === 0 &&
cameraFrameData.current &&
cameraFrameData.current.capturedImageSize
) {
const { width, height } = cameraFrameData.current.capturedImageSize;
originalImageDimensions.current = { width, height };
console.log("✅ originalImageDimensions initialisées depuis cameraFrameData dans onImageLayout:", {
width,
height,
});
}
// ✅ RÉFÉRENTIEL UNIQUE : Recalculer imageDisplayRect dans le wrapper commun
// Si le wrapper commun n'est pas encore prêt, on attendra onCommonWrapperLayout
const wrapper = commonWrapperLayout.current;
if (wrapper.width > 0 && wrapper.height > 0) {
updateImageDisplayRect(wrapper.width, wrapper.height);
}
console.log("Displayed image layout updated:", {
width: layout.width,
height: layout.height,
x: layout.x,
y: layout.y
});
// ✅ CRITICAL FIX #2: Do NOT initialize crop box in onImageLayout for camera images
// Camera images should be initialized ONLY when cameraFrameData is set (in useEffect)
// Gallery images can be initialized here if not already done
if (
wrapper.width > 0 &&
wrapper.height > 0 &&
layout.width > 0 &&
layout.height > 0 &&
!hasInitializedCropBox.current &&
points.length === 0 &&
originalImageDimensions.current.width > 0 &&
originalImageDimensions.current.height > 0
) {
// ✅ CRITICAL: Only initialize for gallery images here
// Camera images should be initialized when cameraFrameData is set, not here
if (imageSource.current !== 'camera') {
initializeCropBox();
} else if (cameraFrameData.current && cameraFrameData.current.greenFrame) {
// ✅ For camera images, initialize ONLY if greenFrame is available
initializeCropBox();
}
}
};
const createPath = () => {
if (points.length < 1) return '';
let path = `M ${points[0].x} ${points[0].y} `;
points.forEach(point => path += `L ${point.x} ${point.y} `);
return path + 'Z';
};
// ✅ Helper function: Find closest point on a line segment to a tap point
const findClosestPointOnLine = (tapX, tapY, lineStartX, lineStartY, lineEndX, lineEndY) => {
const dx = lineEndX - lineStartX;
const dy = lineEndY - lineStartY;
const lengthSquared = dx * dx + dy * dy;
if (lengthSquared === 0) {
// Line segment is a point
return { x: lineStartX, y: lineStartY, distance: Math.sqrt((tapX - lineStartX) ** 2 + (tapY - lineStartY) ** 2) };
}
// Calculate projection parameter t (0 to 1)
const t = Math.max(0, Math.min(1, ((tapX - lineStartX) * dx + (tapY - lineStartY) * dy) / lengthSquared));
// Calculate closest point on line segment
const closestX = lineStartX + t * dx;
const closestY = lineStartY + t * dy;
// Calculate distance from tap to closest point
const distance = Math.sqrt((tapX - closestX) ** 2 + (tapY - closestY) ** 2);
return { x: closestX, y: closestY, distance, t };
};
// ✅ Helper function: Check if tap is near any line segment and find closest point
const findClosestPointOnFrame = (tapX, tapY, lineTolerance = 30) => {
if (points.length < 2) return null;
let closestPoint = null;
let minDistance = Infinity;
let insertIndex = -1;
// Check each line segment (closed polygon: last point connects to first)
for (let i = 0; i < points.length; i++) {
const start = points[i];
const end = points[(i + 1) % points.length];
const result = findClosestPointOnLine(tapX, tapY, start.x, start.y, end.x, end.y);
if (result.distance < minDistance && result.distance < lineTolerance) {
minDistance = result.distance;
closestPoint = { x: result.x, y: result.y };
// Insert after the start point of this segment
insertIndex = i + 1;
}
}
return closestPoint ? { point: closestPoint, insertIndex } : null;
};
const handleTap = (e) => {
if (!image || showResult || points.length === 0) return;
const now = Date.now();
const { locationX: tapX, locationY: tapY } = e.nativeEvent;
const selectRadius = Math.max(18, Math.round(26 * responsiveScale));
const index = points.findIndex((p) => Math.abs(p.x - tapX) < selectRadius && Math.abs(p.y - tapY) < selectRadius);
if (index !== -1) {
selectedPointIndex.current = index;
initialTouchPosition.current = { x: tapX, y: tapY };
lastTouchPosition.current = { x: tapX, y: tapY };
lastTap.current = now;
return;
}
// Restore UX: double tap near an edge inserts a new draggable point.
const isDoubleTap = lastTap.current && now - lastTap.current < 300;
if (isDoubleTap && points.length >= 2) {
const lineResult = findClosestPointOnFrame(tapX, tapY, 30);
if (lineResult) {
const { point, insertIndex } = lineResult;
const exists = points.some((p) => Math.abs(p.x - point.x) < selectRadius && Math.abs(p.y - point.y) < selectRadius);
if (!exists) {
const newPoints = [...points];
newPoints.splice(insertIndex, 0, point);
setPoints(newPoints);
lastTap.current = null;
return;
}
}
}
lastTap.current = now;
};
const handleMove = (e) => {
if (showResult || selectedPointIndex.current === null) return;
const nativeEvent = e.nativeEvent;
const currentX = nativeEvent.locationX;
const currentY = nativeEvent.locationY;
if (currentX === undefined || currentY === undefined || isNaN(currentX) || isNaN(currentY)) return;
let deltaX = 0;
let deltaY = 0;
if (lastTouchPosition.current) {
deltaX = currentX - lastTouchPosition.current.x;
deltaY = currentY - lastTouchPosition.current.y;
} else if (initialTouchPosition.current) {
deltaX = currentX - initialTouchPosition.current.x;
deltaY = currentY - initialTouchPosition.current.y;
}
let imageRect = imageDisplayRect.current;
const wrapper = commonWrapperLayout.current;
if (imageRect.width === 0 || imageRect.height === 0) {
if (wrapper.width > 0 && wrapper.height > 0 && originalImageDimensions.current.width > 0) {
updateImageDisplayRect(wrapper.width, wrapper.height);
imageRect = imageDisplayRect.current;
}
if (imageRect.width === 0 || imageRect.height === 0) {
imageRect = { x: 0, y: 0, width: wrapper.width || 0, height: wrapper.height || 0 };
}
}
lastTouchPosition.current = { x: currentX, y: currentY };
setPoints((prev) => {
if (!Array.isArray(prev) || prev.length === 0) return prev;
const pointIndex = selectedPointIndex.current;
if (pointIndex === null || pointIndex < 0 || pointIndex >= prev.length) return prev;
return prev.map((p, i) => {
if (i !== pointIndex) return p;
return {
x: Math.max(imageRect.x, Math.min(p.x + deltaX, imageRect.x + imageRect.width)),
y: Math.max(imageRect.y, Math.min(p.y + deltaY, imageRect.y + imageRect.height)),
};
});
});
};
const handleRelease = () => {
const wasDragging = selectedPointIndex.current !== null;
// ✅ FREE DRAG: Clear initial positions when drag ends
initialTouchPosition.current = null;
initialPointPosition.current = null;
lastTouchPosition.current = null;
lastValidPosition.current = null;
touchOffset.current = null;
wasClampedLastFrame.current = { x: false, y: false };
selectedPointIndex.current = null;
// ✅ CRITICAL: Re-enable parent ScrollView scrolling when drag ends
if (wasDragging) {
try {
// Re-enable scrolling after a short delay to avoid conflicts
setTimeout(() => {
// ScrollView will be re-enabled automatically when responder is released
}, 100);
} catch (e) {
// Ignore errors
}
}
};
const handleReset = () => {
// setPoints([]);
hasInitializedCropBox.current = false; // ✅ CRITICAL: Reset guard to allow reinitialization
initializeCropBox();
};
// ✅ REFACTORISATION : Stocker l'angle de rotation au lieu de modifier l'image immédiatement
// La rotation sera appliquée uniquement lors du crop final pour éviter les interpolations multiples
const rotatePreviewImage = async (degrees) => {
if (!image) return;
if (rotationInProgressRef.current) return; // block duplicate taps immediately (no re-render delay)
rotationInProgressRef.current = true;
setIsRotating(true);
try {
rotationAngle.current = (rotationAngle.current + degrees) % 360;
// Use JPEG for preview rotation (faster than PNG for large images; quality 0.92 is fine for preview)
const rotated = await ImageManipulator.manipulateAsync(
image,
[{ rotate: degrees }],
{
compress: 0.92,
format: ImageManipulator.SaveFormat.JPEG,
}
);
// ✅ Send rotated image to backend: use rotated URI and dimensions so crop bbox matches
sourceImageUri.current = rotated.uri;
originalImageDimensions.current = {
width: rotated.width,
height: rotated.height,
};
cameraFrameData.current = null; // rotated image is no longer "camera preview" frame
imageSource.current = 'gallery'; // so layout callbacks run initializeCropBox() and show the white box
setPoints([]);
hasInitializedCropBox.current = false;
setImage(rotated.uri);
console.log("Rotation applied:", degrees, "degrees; accumulated:", rotationAngle.current);
} catch (error) {
console.error("Error rotating image:", error);
alert("Error rotating image");
} finally {
rotationInProgressRef.current = false;
setIsRotating(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);
});
};
const getFrameBounds = () => {
if (!points || points.length < 4) return null;
const xs = points.map((p) => p.x);
const ys = points.map((p) => p.y);
return {
minX: Math.min(...xs),
maxX: Math.max(...xs),
minY: Math.min(...ys),
maxY: Math.max(...ys),
};
};
return (
<View style={styles.container}>
{showCustomCamera ? (
<CustomCamera
instructionText={cameraInstructionText}
onPhotoCaptured={(uri, frameData) => {
// ✅ CRITICAL FIX: Store green frame coordinates for coordinate conversion
if (frameData && frameData.greenFrame) {
cameraFrameData.current = {
greenFrame: frameData.greenFrame,
capturedImageSize: frameData.capturedImageSize
};
// ✅ CRITICAL: Set imageSource to 'camera' IMMEDIATELY to prevent Image.getSize() from being called
imageSource.current = 'camera';
hasInitializedCropBox.current = false; // Reset guard for new camera image
console.log("✅ Camera frame data received:", cameraFrameData.current);
}
setImage(uri);
setShowCustomCamera(false);
// ✅ CORRECTION : Réinitialiser les points et l'angle de rotation quand une nouvelle photo est capturée
setPoints([]);
rotationAngle.current = 0;
// ✅ CRITICAL: initializeCropBox will be called automatically when image layout is ready
// The green frame coordinates are stored in cameraFrameData.current and will be used
}}
onCancel={() => setShowCustomCamera(false)}
/>
) : (
<View style={styles.content}>
{image && (
<View style={styles.imageRegion}>
<View
style={styles.commonWrapper}
ref={commonWrapperRef}
onLayout={onCommonWrapperLayout}
>
<View
ref={viewRef}
collapsable={false}
style={StyleSheet.absoluteFill}
onStartShouldSetResponder={() => true}
onMoveShouldSetResponder={(evt, gestureState) => {
// ✅ CRITICAL: Always capture movement when a point is selected
// This ensures vertical movement is captured correctly
if (selectedPointIndex.current !== null) {
return true;
}
// ✅ CRITICAL: Capture ANY movement immediately (even 0px) to prevent ScrollView interception
// This is especially important for vertical movement which ScrollView tries to intercept
// We return true for ANY movement to ensure we capture it before ScrollView
const hasMovement = Math.abs(gestureState.dx) > 0 || Math.abs(gestureState.dy) > 0;
if (hasMovement && Math.abs(gestureState.dy) > 5) {
console.log("🔄 Vertical movement detected in responder:", {
dx: gestureState.dx.toFixed(2),
dy: gestureState.dy.toFixed(2),
selectedPoint: selectedPointIndex.current
});
}
return true;
}}
onResponderGrant={(e) => {
// ✅ CRITICAL: Grant responder immediately to prevent ScrollView from intercepting
// This ensures we capture all movement, especially vertical
// Handle tap to select point if needed
if (selectedPointIndex.current === null) {
handleTap(e);
}
}}
onResponderStart={handleTap}
onResponderMove={(e) => {
// ✅ CRITICAL: Always handle move events to ensure smooth movement in all directions
// This is called for every move event, ensuring vertical movement is captured
// handleMove now uses incremental delta calculation which is more reliable
handleMove(e);
}}
onResponderRelease={handleRelease}
onResponderTerminationRequest={() => {
// ✅ CRITICAL: Never allow termination when dragging a point
// This prevents ScrollView from stealing the responder during vertical movement
return selectedPointIndex.current === null;
}}
// ✅ CRITICAL: Prevent parent ScrollView from intercepting touches
// Capture responder BEFORE parent ScrollView can intercept
onStartShouldSetResponderCapture={() => {
// Always capture start events
return true;
}}
onMoveShouldSetResponderCapture={(evt, gestureState) => {
// ✅ CRITICAL: Always capture movement events before parent ScrollView
// This is essential for vertical movement which ScrollView tries to intercept
// Especially important when a point is selected or when there's any movement
if (selectedPointIndex.current !== null) {
return true;
}
// ✅ CRITICAL: Capture movement BEFORE ScrollView can intercept
// This ensures we get vertical movement even if ScrollView tries to steal it
const hasMovement = Math.abs(gestureState.dx) > 0 || Math.abs(gestureState.dy) > 0;
return hasMovement;
}}
>
<Image
source={{ uri: image }}
style={styles.image}
resizeMode={cameraFrameData.current?.greenFrame ? 'cover' : 'contain'}
onLayout={onImageLayout}
/>
{/* ✅ RÉFÉRENTIEL UNIQUE : SVG overlay utilise les dimensions du wrapper commun */}
{/* IMPORTANT: prevent SVG overlay from stealing touch events so dragging works reliably */}
<Svg style={styles.overlay} pointerEvents="none">
{(() => {
// ✅ Use wrapper dimensions for SVG path (wrapper coordinates)
const wrapperWidth = commonWrapperLayout.current.width || windowWidth;
const wrapperHeight = commonWrapperLayout.current.height || windowHeight;
// ✅ Size scale: when picture area is smaller, make stroke/points smaller too
const base = Math.min(windowWidth, windowHeight) || 1;
const pictureDiagonal = Math.sqrt(wrapperWidth * wrapperWidth + wrapperHeight * wrapperHeight);
const sizeScaleRaw = pictureDiagonal / base;
const sizeScale = Math.max(0.6, Math.min(1.4, sizeScaleRaw)); // clamp
const stroke = Math.max(0.75, 2 * responsiveScale * sizeScale);
const handleRadius = Math.max(5, 9 * responsiveScale * sizeScale);
return (
<>
<Path
d={`M 0 0 H ${wrapperWidth} V ${wrapperHeight} H 0 Z ${createPath()}`}
fill={showResult ? 'white' : 'rgba(0, 0, 0, 0.8)'}
fillRule="evenodd"
/>
{!showResult && points.length > 0 && (
<Path d={createPath()} fill="transparent" stroke="rgba(255,255,255,0.95)" strokeWidth={stroke} />
)}
{!showResult && points.map((point, index) => (
<Circle key={`dot-${index}`} cx={point.x} cy={point.y} r={handleRadius} fill="#E7FFD9" stroke={ACCENT_GREEN} strokeWidth={1.5} />
))}
</>
);
})()}
</Svg>
{!showResult && image && (
<>
{/* <View style={[styles.topBar, { paddingTop: Math.max(insets.top + 8, 18) }]}>
<TouchableOpacity style={styles.topIconButton} activeOpacity={0.8}>
<Ionicons name="flash" size={18} color={ACCENT_GREEN} />
</TouchableOpacity>
<Text style={[styles.topBarTitle, { fontSize: Math.max(22, Math.round(30 * responsiveScale)) }]}>Precision Scan</Text>
<TouchableOpacity style={styles.topIconButton} onPress={handleReset} activeOpacity={0.8}>
<Ionicons name="settings-outline" size={20} color="white" />
</TouchableOpacity>
</View> */}
{/* <View style={styles.detectBadge}>
<Text style={styles.detectBadgeText}>A4 Document Detected</Text>
</View> */}
</>
)}
</View>
{isRotating && (
<View style={{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" color={PRIMARY_GREEN} />
<Text style={{ color: PRIMARY_GREEN, marginTop: 8 * responsiveScale, fontSize: Math.max(12, Math.round(14 * responsiveScale)) }}>{rotationLabel ?? 'Rotation...'}</Text>
</View>
)}
</View>
</View>
)}
{/* ✅ Buttons positioned as a responsive bottom bar (scale with screen size) */}
{!showResult && image && (
<View
style={[
styles.buttonContainerBelow,
{
// Keep controls clearly above home indicator / bottom gesture bar.
paddingBottom: Math.max(insets.bottom + Math.round(12 * responsiveScale), Math.round(20 * responsiveScale)),
paddingTop: Math.round(10 * responsiveScale),
paddingHorizontal: Math.round(12 * responsiveScale),
},
]}
>
<View style={styles.controlsRow}>
<TouchableOpacity
style={[
styles.roundControl,
{
width: 'auto',
minWidth: Math.max(
Math.round(84 * responsiveScale),
Math.round(((cancelText ? String(cancelText).length : 6) * 7 + 32) * responsiveScale)
),
paddingHorizontal: Math.round(12 * responsiveScale),
},
]}
onPress={() => {
if (onCancel) {
onCancel();
}
}}
activeOpacity={0.85}
>
<Text
style={[
styles.buttonText,
{
fontSize: Math.max(10, Math.round(11 * responsiveScale)),
letterSpacing: 0.5,
},
]}
>
{cancelText}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.enhanceButton}
onPress={handleReset}
activeOpacity={0.85}
>
<AntDesign name="rollback" size={Math.round(14 * responsiveScale)} color="rgba(255,255,255,0.95)" />
<Text
style={[
styles.buttonText,
{
fontSize: Math.max(10, Math.round(11 * responsiveScale)),
letterSpacing: 0.5,
},
]}
>
{resetText || 'Enhance'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.rotationButton,
{
minWidth: Math.max(
Math.round(88 * responsiveScale),
Math.round(((rotateText ? String(rotateText).length : 6) * 7 + 38) * responsiveScale)
),
},
isRotating && { opacity: 0.7 },
]}
onPress={() => enableRotation && rotatePreviewImage(90)}
disabled={isRotating}
activeOpacity={0.85}