UNPKG

react-native-expo-cropper

Version:
1,158 lines (1,028 loc) 63.8 kB
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}