react-native-expo-cropper 1.2.37 → 1.2.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,9 @@
1
1
  import styles from './ImageCropperStyles';
2
2
  import React, { useState, useRef, useEffect } from 'react';
3
- import { Modal,View, Image, Dimensions, TouchableOpacity, Animated, Text, Platform, SafeAreaView, PixelRatio } from 'react-native';
3
+ import { Modal, View, Image, Dimensions, TouchableOpacity, Animated, Text, Platform, SafeAreaView, PixelRatio, StyleSheet } from 'react-native';
4
4
  import Svg, { Path, Circle } from 'react-native-svg';
5
- import { captureRef } from 'react-native-view-shot';
6
5
  import CustomCamera from './CustomCamera';
7
6
  import * as ImageManipulator from 'expo-image-manipulator';
8
- import * as FileSystem from 'expo-file-system';
9
7
  import { Ionicons } from '@expo/vector-icons';
10
8
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
11
9
  import { applyMaskToImage, MaskView } from './ImageMaskProcessor';
@@ -22,6 +20,11 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage ,addheight}) =>
22
20
  const sourceImageUri = useRef(null); // keep original image URI (full-res) for upload
23
21
  const cameraFrameData = useRef(null); // ✅ Store green frame coordinates from camera
24
22
 
23
+ // ✅ RÉFÉRENTIEL UNIQUE : Wrapper commun 9/16 (identique à CustomCamera)
24
+ // Ce wrapper est utilisé dans CustomCamera ET ImageCropper pour garantir pixel-perfect matching
25
+ const commonWrapperRef = useRef(null);
26
+ const commonWrapperLayout = useRef({ x: 0, y: 0, width: 0, height: 0 });
27
+
25
28
  // ✅ REFACTORISATION : Séparation claire entre dimensions originales et affichage
26
29
  // Dimensions réelles de l'image originale (pixels)
27
30
  const originalImageDimensions = useRef({ width: 0, height: 0 });
@@ -29,53 +32,94 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage ,addheight}) =>
29
32
  const displayedImageLayout = useRef({ x: 0, y: 0, width: 0, height: 0 });
30
33
  // Conserver imageMeasure pour compatibilité avec le code existant (utilisé pour SVG overlay)
31
34
  const imageMeasure = useRef({ x: 0, y: 0, width: 0, height: 0 });
32
- // Rectangle réel de l'image affichée (quand resizeMode='contain') à l'intérieur du conteneur
33
- // Sert à rendre la conversion coordonnées écran -> pixels image réellement pixel-perfect.
34
- const displayedContentRect = useRef({ x: 0, y: 0, width: 0, height: 0 });
35
+ // ✅ imageDisplayRect : Rectangle réel de l'image affichée (quand resizeMode='contain') à l'intérieur du wrapper commun
36
+ // C'est la zone l'image est réellement visible (avec letterboxing si nécessaire)
37
+ // Les points de crop DOIVENT rester dans cette zone pour éviter de cropper hors de l'image
38
+ const imageDisplayRect = useRef({ x: 0, y: 0, width: 0, height: 0 });
39
+
40
+ // ✅ COMPATIBILITÉ : displayedContentRect reste pour le code existant, mais pointe vers imageDisplayRect
41
+ const displayedContentRect = imageDisplayRect;
35
42
 
36
- const updateDisplayedContentRect = (layoutWidth, layoutHeight) => {
43
+ // RÉFÉRENTIEL UNIQUE : Calculer imageDisplayRect à l'intérieur du wrapper commun
44
+ // - CAMERA: use "cover" mode → image fills wrapper, imageDisplayRect = full wrapper (same as preview)
45
+ // - GALLERY: use "contain" mode → imageDisplayRect = letterboxed area
46
+ const updateImageDisplayRect = (wrapperWidth, wrapperHeight) => {
37
47
  const iw = originalImageDimensions.current.width;
38
48
  const ih = originalImageDimensions.current.height;
39
49
 
40
- console.log("🔄 updateDisplayedContentRect called:", {
50
+ // CAMERA IMAGE: Use full wrapper so green frame and white frame show same content
51
+ if (cameraFrameData.current && cameraFrameData.current.greenFrame && wrapperWidth > 0 && wrapperHeight > 0) {
52
+ imageDisplayRect.current = { x: 0, y: 0, width: wrapperWidth, height: wrapperHeight };
53
+ console.log("✅ Image display rect (COVER mode for camera - full wrapper):", imageDisplayRect.current);
54
+ return;
55
+ }
56
+
57
+ console.log("🔄 updateImageDisplayRect called:", {
41
58
  originalDimensions: { width: iw, height: ih },
42
- layoutDimensions: { width: layoutWidth, height: layoutHeight }
59
+ wrapperDimensions: { width: wrapperWidth, height: wrapperHeight }
43
60
  });
44
61
 
45
- if (iw > 0 && ih > 0 && layoutWidth > 0 && layoutHeight > 0) {
46
- const scale = Math.min(layoutWidth / iw, layoutHeight / ih);
47
- const contentW = iw * scale;
48
- const contentH = ih * scale;
49
- const offsetX = (layoutWidth - contentW) / 2;
50
- const offsetY = (layoutHeight - contentH) / 2;
51
- displayedContentRect.current = {
62
+ if (iw > 0 && ih > 0 && wrapperWidth > 0 && wrapperHeight > 0) {
63
+ // Calculer comment l'image s'affiche en "contain" dans le wrapper (gallery)
64
+ const scale = Math.min(wrapperWidth / iw, wrapperHeight / ih);
65
+ const imageDisplayWidth = iw * scale;
66
+ const imageDisplayHeight = ih * scale;
67
+ const offsetX = (wrapperWidth - imageDisplayWidth) / 2;
68
+ const offsetY = (wrapperHeight - imageDisplayHeight) / 2;
69
+
70
+ imageDisplayRect.current = {
52
71
  x: offsetX,
53
72
  y: offsetY,
54
- width: contentW,
55
- height: contentH,
73
+ width: imageDisplayWidth,
74
+ height: imageDisplayHeight,
56
75
  };
57
- console.log("✅ Displayed content rect (contain) calculated:", displayedContentRect.current);
76
+ console.log("✅ Image display rect (contain in wrapper) calculated:", {
77
+ wrapper: { width: wrapperWidth, height: wrapperHeight },
78
+ imageDisplayRect: imageDisplayRect.current,
79
+ scale: scale.toFixed(4)
80
+ });
58
81
  return;
59
82
  }
60
83
 
61
- // FALLBACK: If original dimensions not available yet, use layout as temporary measure
62
- if (layoutWidth > 0 && layoutHeight > 0) {
63
- displayedContentRect.current = { x: 0, y: 0, width: layoutWidth, height: layoutHeight };
64
- console.log("⚠️ Using layout dimensions as fallback (original dimensions not available yet):", displayedContentRect.current);
84
+ if (wrapperWidth > 0 && wrapperHeight > 0) {
85
+ imageDisplayRect.current = { x: 0, y: 0, width: wrapperWidth, height: wrapperHeight };
86
+ console.log("⚠️ Using wrapper dimensions as fallback (original dimensions not available yet):", imageDisplayRect.current);
65
87
  } else {
66
- displayedContentRect.current = { x: 0, y: 0, width: 0, height: 0 };
67
- console.warn("❌ Cannot calculate displayedContentRect: missing dimensions");
88
+ imageDisplayRect.current = { x: 0, y: 0, width: 0, height: 0 };
89
+ console.warn("❌ Cannot calculate imageDisplayRect: missing dimensions");
68
90
  }
69
91
  };
70
92
 
93
+ // ✅ COMPATIBILITÉ : Alias pour le code existant
94
+ const updateDisplayedContentRect = updateImageDisplayRect;
95
+
71
96
  const selectedPointIndex = useRef(null);
72
97
  const lastTap = useRef(null);
73
98
 
99
+ // ✅ CRITICAL: Guard to prevent double crop box initialization
100
+ // This ensures crop box is initialized only once, especially for camera images
101
+ const hasInitializedCropBox = useRef(false);
102
+ const imageSource = useRef(null); // 'camera' | 'gallery' | null
103
+
74
104
  // ✅ FREE DRAG: Store initial touch position and point position for delta-based movement
75
105
  const initialTouchPosition = useRef(null); // { x, y } - initial touch position when drag starts
76
106
  const initialPointPosition = useRef(null); // { x, y } - initial point position when drag starts
77
107
  const lastTouchPosition = useRef(null); // { x, y } - last touch position for incremental delta calculation
78
108
 
109
+ // ✅ CRITICAL: dragBase stores the VISUAL position (can be overshoot) during drag
110
+ // This ensures smooth continuous drag without "dead zones" at boundaries
111
+ // dragBase is updated with applied position (overshoot) after each movement
112
+ // and is used as base for next delta calculation
113
+ const dragBase = useRef(null); // { x, y } - visual position during drag (can be overshoot)
114
+
115
+ // ✅ NEW APPROACH: touchOffset stores the initial offset between point and touch
116
+ // This eliminates delta accumulation issues and "dead zones"
117
+ // Once set at drag start, it remains constant throughout the drag
118
+ const touchOffset = useRef(null); // { x, y } - offset = pointPosition - touchPosition
119
+
120
+ // ✅ Track if point was clamped in previous frame (to detect transition)
121
+ const wasClampedLastFrame = useRef({ x: false, y: false });
122
+
79
123
  // Angle de rotation accumulé (pour éviter les rotations multiples)
80
124
  const rotationAngle = useRef(0);
81
125
 
@@ -92,7 +136,7 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage ,addheight}) =>
92
136
  const insets = useSafeAreaInsets();
93
137
 
94
138
  // ✅ NEW ARCH: mobile does NOT export the final crop.
95
- // We only compute crop metadata (bbox + polygon) and upload the ORIGINAL image to backend Python.
139
+
96
140
  // No view-shot / captureRef / bitmap masking on device.
97
141
  const enableMask = false;
98
142
  const enableRotation = false; // rotation would require careful coord transforms; keep off for pixel-perfect pipeline.
@@ -113,57 +157,115 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage ,addheight}) =>
113
157
  rotationAngle.current = 0;
114
158
  // Clear camera frame data for gallery images
115
159
  cameraFrameData.current = null;
160
+ // ✅ CRITICAL: Reset initialization guard for new image
161
+ hasInitializedCropBox.current = false;
162
+ imageSource.current = null;
116
163
  }
117
164
  }, [openCameraFirst, initialImage]);
118
165
 
119
166
 
120
167
  // ✅ REFACTORISATION : Stocker uniquement les dimensions originales (pas de calcul théorique)
121
- // ✅ CRITICAL FIX: Get dimensions and account for potential scale factor differences
122
- // On Android, Image.getSize() returns physical pixels, but the image sent to backend
123
- // might be at a different scale. We'll send original dimensions to backend for adjustment.
168
+ // ✅ CRITICAL FIX: Single source of truth for image dimensions
169
+
124
170
  useEffect(() => {
125
171
  if (!image) {
126
172
  originalImageDimensions.current = { width: 0, height: 0 };
173
+ hasInitializedCropBox.current = false;
174
+ imageSource.current = null;
127
175
  return;
128
176
  }
129
177
  if (!sourceImageUri.current) {
130
178
  sourceImageUri.current = image;
131
179
  }
132
180
 
133
- // ✅ CRITICAL FIX: If we have capturedImageSize from camera, use it as source of truth
181
+ // ✅ CRITICAL FIX #1: If we have capturedImageSize from camera, use it as SINGLE SOURCE OF TRUTH
134
182
  // takePictureAsync returns physical dimensions, while Image.getSize() may return EXIF-oriented dimensions
183
+ // DO NOT call Image.getSize() for camera images - it can return swapped dimensions on Android
135
184
  if (cameraFrameData.current && cameraFrameData.current.capturedImageSize) {
136
185
  const { width: capturedWidth, height: capturedHeight } = cameraFrameData.current.capturedImageSize;
137
186
  originalImageDimensions.current = {
138
187
  width: capturedWidth,
139
188
  height: capturedHeight,
140
189
  };
141
- console.log("✅ Using captured image dimensions from takePictureAsync:", {
190
+ imageSource.current = 'camera';
191
+ hasInitializedCropBox.current = false; // Reset guard for new camera image
192
+
193
+ console.log("✅ Using captured image dimensions from takePictureAsync (SINGLE SOURCE OF TRUTH):", {
142
194
  width: capturedWidth,
143
195
  height: capturedHeight,
144
- source: 'takePictureAsync'
196
+ source: 'takePictureAsync',
197
+ note: 'Image.getSize() will NOT be called for camera images'
145
198
  });
146
- // ✅ CRITICAL: Use displayedImageLayout dimensions if available, otherwise wait for onImageLayout
147
- const lw = displayedImageLayout.current.width;
148
- const lh = displayedImageLayout.current.height;
149
- if (lw > 0 && lh > 0) {
150
- updateDisplayedContentRect(lw, lh);
151
- // ✅ CRITICAL: Initialize crop box when we have camera frame data and image dimensions
152
- if (points.length === 0) {
199
+
200
+ // CRITICAL: Recalculate imageDisplayRect immediately with camera dimensions
201
+ const wrapper = commonWrapperLayout.current;
202
+ if (wrapper.width > 0 && wrapper.height > 0) {
203
+ updateImageDisplayRect(wrapper.width, wrapper.height);
204
+
205
+ // CRITICAL FIX #2: Initialize crop box immediately when cameraFrameData is available
206
+ // This ensures camera images are initialized from greenFrame BEFORE any other initialization
207
+ if (cameraFrameData.current && cameraFrameData.current.greenFrame && !hasInitializedCropBox.current) {
208
+ console.log("✅ Initializing crop box from cameraFrameData (immediate in useEffect):", {
209
+ hasGreenFrame: !!cameraFrameData.current.greenFrame,
210
+ wrapper: wrapper,
211
+ originalDimensions: originalImageDimensions.current
212
+ });
153
213
  initializeCropBox();
154
214
  }
155
215
  }
156
- return;
216
+
217
+ return; // ✅ CRITICAL: Exit early - DO NOT call Image.getSize()
157
218
  }
158
219
 
159
- // ✅ FALLBACK: Use Image.getSize() if no captured dimensions available (e.g., from gallery)
220
+ // ✅ FALLBACK: Use Image.getSize() ONLY for gallery images (no cameraFrameData)
221
+ // BUT: Check again right before calling to avoid race condition
222
+ if (cameraFrameData.current && cameraFrameData.current.capturedImageSize) {
223
+ console.log("⚠️ cameraFrameData exists, skipping Image.getSize() call");
224
+ return;
225
+ }
226
+
227
+ // ✅ CRITICAL: Also check imageSource - if it's 'camera', don't call Image.getSize()
228
+ if (imageSource.current === 'camera') {
229
+ console.log("⚠️ imageSource is 'camera', skipping Image.getSize() call");
230
+ return;
231
+ }
232
+
233
+ // ✅ CRITICAL: Set imageSource to 'gallery' ONLY if we're sure it's not a camera image
234
+ // Don't set it yet - we'll set it in the callback after verifying
235
+ hasInitializedCropBox.current = false; // Reset guard for new image
236
+
160
237
  Image.getSize(image, (imgWidth, imgHeight) => {
238
+ // ✅ CRITICAL SAFETY #1: Check if cameraFrameData appeared while Image.getSize() was resolving
239
+ // This is the PRIMARY check - cameraFrameData takes precedence
240
+ if (cameraFrameData.current && cameraFrameData.current.capturedImageSize) {
241
+ console.warn("⚠️ Image.getSize() resolved but cameraFrameData exists - IGNORING Image.getSize() result to prevent dimension swap");
242
+ console.warn("⚠️ Camera dimensions (correct):", cameraFrameData.current.capturedImageSize);
243
+ console.warn("⚠️ Image.getSize() dimensions (potentially swapped):", { width: imgWidth, height: imgHeight });
244
+ return; // ✅ CRITICAL: Exit early - do NOT update dimensions or initialize crop box
245
+ }
246
+
247
+ // ✅ CRITICAL SAFETY #2: Check imageSource (should be 'camera' if cameraFrameData was set)
248
+ if (imageSource.current === 'camera') {
249
+ console.warn("⚠️ Image.getSize() resolved but imageSource is 'camera' - IGNORING Image.getSize() result");
250
+ console.warn("⚠️ Image.getSize() dimensions (potentially swapped):", { width: imgWidth, height: imgHeight });
251
+ return; // ✅ CRITICAL: Exit early - do NOT update dimensions or initialize crop box
252
+ }
253
+
254
+ // ✅ CRITICAL SAFETY #3: Check if crop box was already initialized (from camera)
255
+ if (hasInitializedCropBox.current) {
256
+ console.warn("⚠️ Image.getSize() resolved but crop box already initialized - IGNORING result to prevent double initialization");
257
+ return;
258
+ }
259
+
260
+ // ✅ SAFE: This is a gallery image, proceed with Image.getSize() result
261
+ imageSource.current = 'gallery';
262
+
161
263
  originalImageDimensions.current = {
162
264
  width: imgWidth,
163
265
  height: imgHeight,
164
266
  };
165
267
 
166
- console.log("✅ Image dimensions from Image.getSize():", {
268
+ console.log("✅ Image dimensions from Image.getSize() (gallery image):", {
167
269
  width: imgWidth,
168
270
  height: imgHeight,
169
271
  platform: Platform.OS,
@@ -172,17 +274,18 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage ,addheight}) =>
172
274
  source: 'Image.getSize()'
173
275
  });
174
276
 
175
- // ✅ IMPORTANT: onImageLayout peut se déclencher avant Image.getSize (race condition).
176
- // Recalculer le contentRect dès qu'on connaît la taille originale, sinon les coords seront décalées.
177
- const lw = displayedImageLayout.current.width;
178
- const lh = displayedImageLayout.current.height;
179
- if (lw > 0 && lh > 0) {
180
- updateDisplayedContentRect(lw, lh);
181
- // CRITICAL: Initialize crop box when we have image dimensions
182
- // - If we have camera frame data, use it to match green frame exactly
183
- // - If no camera frame data (gallery image), initialize with 70% default box
184
- if (points.length === 0) {
277
+ // ✅ RÉFÉRENTIEL UNIQUE : Recalculer imageDisplayRect dans le wrapper commun
278
+ // dès qu'on connaît la taille originale de l'image
279
+ const wrapper = commonWrapperLayout.current;
280
+ if (wrapper.width > 0 && wrapper.height > 0) {
281
+ updateImageDisplayRect(wrapper.width, wrapper.height);
282
+ // ✅ IMPORTANT: pour les images de la galerie (pas de cameraFrameData),
283
+ // initialiser automatiquement le cadre blanc (70% du wrapper) une fois que
284
+ // nous connaissons à la fois le wrapper et les dimensions originales.
285
+ // CRITICAL: Guard against double initialization
286
+ if (!hasInitializedCropBox.current && points.length === 0 && imageSource.current === 'gallery') {
185
287
  initializeCropBox();
288
+ hasInitializedCropBox.current = true;
186
289
  }
187
290
  }
188
291
  }, (error) => {
@@ -191,479 +294,165 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage ,addheight}) =>
191
294
  }, [image]);
192
295
 
193
296
 
194
- // ✅ CRITICAL FIX: Convert green frame coordinates (camera preview) to captured image coordinates
195
- const convertGreenFrameToImageCoords = (greenFrame, capturedImageSize, displayedImageRect) => {
196
- if (!greenFrame || !capturedImageSize) {
197
- console.warn("Cannot convert green frame: missing data");
198
- return null;
199
- }
200
-
201
- const { x: frameX, y: frameY, width: frameWidth, height: frameHeight, wrapperWidth, wrapperHeight } = greenFrame;
202
- const { width: imgWidth, height: imgHeight } = capturedImageSize;
203
- const { x: displayX, y: displayY, width: displayWidth, height: displayHeight } = displayedImageRect;
204
-
205
- console.log("🔄 Converting green frame:", {
206
- greenFrame: { frameX, frameY, frameWidth, frameHeight, wrapperWidth, wrapperHeight },
207
- capturedImageSize: { imgWidth, imgHeight },
208
- displayedImageRect: { displayX, displayY, displayWidth, displayHeight }
209
- });
210
-
211
- // ✅ SIMPLIFIED APPROACH: Assume CameraView fills the wrapper completely (no letterboxing in preview)
212
- // The green frame is drawn as a percentage of the wrapper (95% width, 80% height)
213
- // We need to map this directly to the captured image, accounting for aspect ratio differences
214
-
215
- const previewAspect = wrapperWidth / wrapperHeight;
216
- const capturedAspect = imgWidth / imgHeight;
217
-
218
- console.log("📐 Aspect ratios:", {
219
- previewAspect: previewAspect.toFixed(3),
220
- capturedAspect: capturedAspect.toFixed(3),
221
- wrapperSize: { wrapperWidth, wrapperHeight },
222
- capturedSize: { imgWidth, imgHeight }
223
- });
224
-
225
- // ✅ KEY INSIGHT: The green frame is 95% of wrapper width and 80% of wrapper height
226
- // If the captured image has a different aspect ratio, we need to map proportionally
227
- // The green frame represents a region in the preview, which should map to the same region in the image
228
-
229
- // Calculate green frame as percentage of wrapper
230
- const greenFramePercentX = frameX / wrapperWidth;
231
- const greenFramePercentY = frameY / wrapperHeight;
232
- const greenFramePercentWidth = frameWidth / wrapperWidth;
233
- const greenFramePercentHeight = frameHeight / wrapperHeight;
234
-
235
- console.log("📊 Green frame as percentage of wrapper:", {
236
- x: (greenFramePercentX * 100).toFixed(2) + '%',
237
- y: (greenFramePercentY * 100).toFixed(2) + '%',
238
- width: (greenFramePercentWidth * 100).toFixed(2) + '%',
239
- height: (greenFramePercentHeight * 100).toFixed(2) + '%'
240
- });
241
297
 
242
- // ✅ DIRECT MAPPING: Map green frame percentage directly to captured image
243
- // The green frame covers a certain percentage of the preview, which should map to the same percentage of the image
244
- // However, we need to account for aspect ratio differences
245
-
246
- // If preview and captured have same aspect ratio, direct mapping works
247
- // If different, we need to account for letterboxing in the preview
248
-
249
- // Calculate how the captured image would be displayed in the preview (aspect-fit)
250
- let previewContentWidth, previewContentHeight, previewOffsetX, previewOffsetY;
251
-
252
- if (Math.abs(capturedAspect - previewAspect) < 0.01) {
253
- // Same aspect ratio → no letterboxing, direct mapping
254
- previewContentWidth = wrapperWidth;
255
- previewContentHeight = wrapperHeight;
256
- previewOffsetX = 0;
257
- previewOffsetY = 0;
258
- } else if (capturedAspect > previewAspect) {
259
- // Image is wider → fills width, letterboxing on top/bottom
260
- previewContentWidth = wrapperWidth;
261
- previewContentHeight = wrapperWidth / capturedAspect;
262
- previewOffsetX = 0;
263
- previewOffsetY = (wrapperHeight - previewContentHeight) / 2;
264
- } else {
265
- // Image is taller → fills height, letterboxing on left/right
266
- previewContentHeight = wrapperHeight;
267
- previewContentWidth = wrapperHeight * capturedAspect;
268
- previewOffsetX = (wrapperWidth - previewContentWidth) / 2;
269
- previewOffsetY = 0;
298
+
299
+ // Le cadre blanc doit être calculé sur le MÊME wrapper que le cadre vert (9/16)
300
+ // Ensuite, on restreint les points pour qu'ils restent dans imageDisplayRect (image visible)
301
+ const initializeCropBox = () => {
302
+ // CRITICAL FIX #2: Guard against double initialization
303
+ if (hasInitializedCropBox.current) {
304
+ console.log("⚠️ Crop box already initialized, skipping duplicate initialization");
305
+ return;
270
306
  }
271
-
272
- console.log("📐 Preview content area (actual image area in preview):", {
273
- previewContentWidth: previewContentWidth.toFixed(2),
274
- previewContentHeight: previewContentHeight.toFixed(2),
275
- previewOffsetX: previewOffsetX.toFixed(2),
276
- previewOffsetY: previewOffsetY.toFixed(2)
277
- });
278
-
279
- // Step 3: Convert green frame coordinates from wrapper space to preview content space
280
- // ✅ CRITICAL FIX: The green frame is drawn on the wrapper, but we need to map it to the actual image area
281
- // If the green frame overlaps letterboxing areas, we need to clip it to the actual image content area
282
-
283
- // Calculate green frame bounds in wrapper coordinates
284
- const frameLeft = frameX;
285
- const frameTop = frameY;
286
- const frameRight = frameX + frameWidth;
287
- const frameBottom = frameY + frameHeight;
288
-
289
- // Calculate preview content bounds in wrapper coordinates
290
- const contentLeft = previewOffsetX;
291
- const contentTop = previewOffsetY;
292
- const contentRight = previewOffsetX + previewContentWidth;
293
- const contentBottom = previewOffsetY + previewContentHeight;
294
307
 
295
- // ✅ KEY INSIGHT: The green frame should map to the same percentage of the image content area
296
- // But we need to account for letterboxing - the green frame might extend into letterboxing areas
297
-
298
- // Clip green frame to preview content area (intersection)
299
- const clippedLeft = Math.max(frameLeft, contentLeft);
300
- const clippedTop = Math.max(frameTop, contentTop);
301
- const clippedRight = Math.min(frameRight, contentRight);
302
- const clippedBottom = Math.min(frameBottom, contentBottom);
303
-
304
- // Calculate clipped green frame dimensions
305
- const clippedWidth = Math.max(0, clippedRight - clippedLeft);
306
- const clippedHeight = Math.max(0, clippedBottom - clippedTop);
307
-
308
- // If green frame is completely outside content area, return null
309
- if (clippedWidth <= 0 || clippedHeight <= 0) {
310
- console.error("❌ Green frame is completely outside preview content area!");
311
- return null;
308
+ // ✅ CRITICAL: Ensure common wrapper layout is available
309
+ const wrapper = commonWrapperLayout.current;
310
+ if (wrapper.width === 0 || wrapper.height === 0) {
311
+ console.warn("Cannot initialize crop box: common wrapper layout not ready");
312
+ return;
312
313
  }
313
314
 
314
- // ✅ ALTERNATIVE APPROACH: Map green frame as percentage of image content area
315
- // The green frame covers a certain percentage of the wrapper, but we want it to cover
316
- // the same visual percentage of the image content area
317
-
318
- // Calculate green frame center and size as percentage of wrapper
319
- const greenFrameCenterX = (frameLeft + frameRight) / 2;
320
- const greenFrameCenterY = (frameTop + frameBottom) / 2;
321
- const greenFrameCenterPercentX = greenFrameCenterX / wrapperWidth;
322
- const greenFrameCenterPercentY = greenFrameCenterY / wrapperHeight;
323
-
324
- // Map center to image content area
325
- const imageContentCenterX = previewOffsetX + previewContentWidth * greenFrameCenterPercentX;
326
- const imageContentCenterY = previewOffsetY + previewContentHeight * greenFrameCenterPercentY;
327
-
328
- // Calculate green frame size as percentage of image content area (not wrapper)
329
- // The green frame should cover the same visual percentage of the image as it does of the wrapper
330
- const imageContentFrameWidth = previewContentWidth * greenFramePercentWidth;
331
- const imageContentFrameHeight = previewContentHeight * greenFramePercentHeight;
332
-
333
- // Calculate final green frame in image content coordinates
334
- const finalFrameX = imageContentCenterX - imageContentFrameWidth / 2;
335
- const finalFrameY = imageContentCenterY - imageContentFrameHeight / 2;
336
- const finalFrameWidth = imageContentFrameWidth;
337
- const finalFrameHeight = imageContentFrameHeight;
338
-
339
- // Clamp to image content bounds
340
- const clampedFinalX = Math.max(previewOffsetX, Math.min(finalFrameX, previewOffsetX + previewContentWidth - finalFrameWidth));
341
- const clampedFinalY = Math.max(previewOffsetY, Math.min(finalFrameY, previewOffsetY + previewContentHeight - finalFrameHeight));
342
- const clampedFinalWidth = Math.min(finalFrameWidth, previewOffsetX + previewContentWidth - clampedFinalX);
343
- const clampedFinalHeight = Math.min(finalFrameHeight, previewOffsetY + previewContentHeight - clampedFinalY);
344
-
345
- // Convert to relative coordinates within preview content area
346
- const relativeX = clampedFinalX - previewOffsetX;
347
- const relativeY = clampedFinalY - previewOffsetY;
348
-
349
- // Normalize to 0-1 range within the preview content area (actual image area)
350
- const normalizedX = relativeX / previewContentWidth;
351
- const normalizedY = relativeY / previewContentHeight;
352
- const normalizedWidth = clampedFinalWidth / previewContentWidth;
353
- const normalizedHeight = clampedFinalHeight / previewContentHeight;
354
-
355
- console.log("✂️ Green frame mapping (percentage-based):", {
356
- originalFrame: { frameX, frameY, frameWidth, frameHeight },
357
- greenFramePercentages: {
358
- centerX: (greenFrameCenterPercentX * 100).toFixed(2) + '%',
359
- centerY: (greenFrameCenterPercentY * 100).toFixed(2) + '%',
360
- width: (greenFramePercentWidth * 100).toFixed(2) + '%',
361
- height: (greenFramePercentHeight * 100).toFixed(2) + '%'
362
- },
363
- previewContent: { previewOffsetX, previewOffsetY, previewContentWidth, previewContentHeight },
364
- mappedFrame: {
365
- finalX: finalFrameX.toFixed(2),
366
- finalY: finalFrameY.toFixed(2),
367
- finalWidth: finalFrameWidth.toFixed(2),
368
- finalHeight: finalFrameHeight.toFixed(2)
369
- },
370
- clampedFrame: {
371
- x: clampedFinalX.toFixed(2),
372
- y: clampedFinalY.toFixed(2),
373
- width: clampedFinalWidth.toFixed(2),
374
- height: clampedFinalHeight.toFixed(2)
375
- },
376
- normalized: {
377
- normalizedX: normalizedX.toFixed(4),
378
- normalizedY: normalizedY.toFixed(4),
379
- normalizedWidth: normalizedWidth.toFixed(4),
380
- normalizedHeight: normalizedHeight.toFixed(4)
315
+ // ✅ CRITICAL: Ensure imageDisplayRect is available (zone réelle de l'image dans le wrapper)
316
+ let imageRect = imageDisplayRect.current;
317
+ if (imageRect.width === 0 || imageRect.height === 0) {
318
+ // Recalculer si nécessaire
319
+ if (originalImageDimensions.current.width > 0 && originalImageDimensions.current.height > 0) {
320
+ updateImageDisplayRect(wrapper.width, wrapper.height);
321
+ imageRect = imageDisplayRect.current;
322
+ } else {
323
+ console.warn("Cannot initialize crop box: imageDisplayRect not available (original dimensions missing)");
324
+ return;
381
325
  }
382
- });
383
-
384
- console.log("📊 Normalized coordinates:", {
385
- relativeX,
386
- relativeY,
387
- normalizedX,
388
- normalizedY,
389
- normalizedWidth,
390
- normalizedHeight
391
- });
392
-
393
- // Step 4: Convert normalized coordinates to captured image pixel coordinates
394
- const imageX = normalizedX * imgWidth;
395
- const imageY = normalizedY * imgHeight;
396
- const imageWidth = normalizedWidth * imgWidth;
397
- const imageHeight = normalizedHeight * imgHeight;
398
-
399
- console.log("🖼️ Image pixel coordinates:", {
400
- imageX,
401
- imageY,
402
- imageWidth,
403
- imageHeight,
404
- imgWidth,
405
- imgHeight
406
- });
407
-
408
- // Step 5: Convert to displayed image coordinates (for white bounding box)
409
- // The captured image is displayed with resizeMode='contain' in ImageCropper
410
- const displayAspect = displayWidth / displayHeight;
411
- let displayContentWidth, displayContentHeight, displayOffsetX, displayOffsetY;
412
-
413
- if (capturedAspect > displayAspect) {
414
- // Image is wider than display → letterboxing on top/bottom
415
- displayContentWidth = displayWidth;
416
- displayContentHeight = displayWidth / capturedAspect;
417
- displayOffsetX = displayX;
418
- displayOffsetY = displayY + (displayHeight - displayContentHeight) / 2;
419
- } else {
420
- // Image is taller than display → letterboxing on left/right
421
- displayContentHeight = displayHeight;
422
- displayContentWidth = displayHeight * capturedAspect;
423
- displayOffsetX = displayX + (displayWidth - displayContentWidth) / 2;
424
- displayOffsetY = displayY;
425
326
  }
426
327
 
427
- // Convert image pixel coordinates to display coordinates
428
- const scaleX = displayContentWidth / imgWidth;
429
- const scaleY = displayContentHeight / imgHeight;
328
+ // CRITICAL FIX: Calculate crop box as percentage of VISIBLE IMAGE AREA (imageDisplayRect)
329
+ // NOT the wrapper. This ensures the crop box is truly 80% of the image, not 80% of wrapper then clamped.
330
+ // Calculate absolute position of imageDisplayRect within wrapper
331
+ const imageRectX = wrapper.x + imageRect.x;
332
+ const imageRectY = wrapper.y + imageRect.y;
430
333
 
431
- const displayBoxX = displayOffsetX + imageX * scaleX;
432
- const displayBoxY = displayOffsetY + imageY * scaleY;
433
- const displayBoxWidth = imageWidth * scaleX;
434
- const displayBoxHeight = imageHeight * scaleY;
435
-
436
- // Clamp to displayed image bounds
437
- const clampedX = Math.max(displayOffsetX, Math.min(displayBoxX, displayOffsetX + displayContentWidth - displayBoxWidth));
438
- const clampedY = Math.max(displayOffsetY, Math.min(displayBoxY, displayOffsetY + displayContentHeight - displayBoxHeight));
439
- const clampedWidth = Math.min(displayBoxWidth, displayOffsetX + displayContentWidth - clampedX);
440
- const clampedHeight = Math.min(displayBoxHeight, displayOffsetY + displayContentHeight - clampedY);
334
+ // PRIORITY RULE #3: IF image comes from camera → Use EXACT green frame coordinates
335
+ // Image is displayed in "cover" mode (full wrapper), so green frame coords = white frame coords
336
+ if (cameraFrameData.current && cameraFrameData.current.greenFrame && originalImageDimensions.current.width > 0) {
337
+ const greenFrame = cameraFrameData.current.greenFrame;
338
+
339
+ // Image fills wrapper (cover mode), so use green frame position directly in wrapper space
340
+ const boxX = wrapper.x + greenFrame.x;
341
+ const boxY = wrapper.y + greenFrame.y;
342
+ const boxWidth = greenFrame.width;
343
+ const boxHeight = greenFrame.height;
441
344
 
442
- const result = {
443
- // Display coordinates (for white bounding box)
444
- displayCoords: {
445
- x: clampedX,
446
- y: clampedY,
447
- width: clampedWidth,
448
- height: clampedHeight
449
- },
450
- // Image pixel coordinates (for backend crop)
451
- imageCoords: {
452
- x: Math.max(0, Math.min(imageX, imgWidth - imageWidth)),
453
- y: Math.max(0, Math.min(imageY, imgHeight - imageHeight)),
454
- width: Math.max(0, Math.min(imageWidth, imgWidth)),
455
- height: Math.max(0, Math.min(imageHeight, imgHeight))
456
- },
457
- debug: {
458
- previewAspect,
459
- capturedAspect,
460
- displayAspect,
461
- previewContentWidth,
462
- previewContentHeight,
463
- previewOffsetX,
464
- previewOffsetY,
465
- displayContentWidth,
466
- displayContentHeight,
467
- displayOffsetX,
468
- displayOffsetY,
469
- scaleX,
470
- scaleY
345
+ // SAFETY: Validate calculated coordinates before creating points
346
+ const isValidCoordinate = (val) => typeof val === 'number' && isFinite(val) && !isNaN(val);
347
+
348
+ if (!isValidCoordinate(boxX) || !isValidCoordinate(boxY) ||
349
+ !isValidCoordinate(boxWidth) || !isValidCoordinate(boxHeight)) {
350
+ console.warn("⚠️ Invalid coordinates calculated for crop box, skipping initialization");
351
+ return;
352
+ }
353
+
354
+ // ✅ Create points using EXACT greenFrame coordinates (mapped to ImageCropper wrapper)
355
+ const newPoints = [
356
+ { x: boxX, y: boxY }, // Top-left
357
+ { x: boxX + boxWidth, y: boxY }, // Top-right
358
+ { x: boxX + boxWidth, y: boxY + boxHeight }, // Bottom-right
359
+ { x: boxX, y: boxY + boxHeight }, // Bottom-left
360
+ ];
361
+
362
+ // ✅ SAFETY: Validate all points before setting
363
+ const validPoints = newPoints.filter(p => isValidCoordinate(p.x) && isValidCoordinate(p.y));
364
+ if (validPoints.length !== newPoints.length) {
365
+ console.warn("⚠️ Some points have invalid coordinates, skipping initialization");
366
+ return;
471
367
  }
472
- };
473
368
 
474
- console.log("✅ Green frame converted to image coordinates:", JSON.stringify(result, null, 2));
475
-
476
- // VALIDATION: Ensure the white bounding box matches the green frame visually
477
- // Calculate what percentage of the image the green frame covers
478
- const greenFramePercentOfImage = {
479
- width: (imageWidth / imgWidth) * 100,
480
- height: (imageHeight / imgHeight) * 100
481
- };
482
- const greenFramePercentOfPreview = {
483
- width: (frameWidth / wrapperWidth) * 100,
484
- height: (frameHeight / wrapperHeight) * 100
485
- };
486
-
487
- console.log("📏 Green frame coverage:", {
488
- percentOfImage: greenFramePercentOfImage,
489
- percentOfPreview: greenFramePercentOfPreview,
490
- shouldMatch: "Green frame should cover same % of image as it does of preview"
491
- });
492
-
493
- return result;
494
- };
369
+ console.log("✅ Initializing crop box for camera image (COVER MODE - exact green frame):", {
370
+ greenFrame: { x: greenFrame.x, y: greenFrame.y, width: greenFrame.width, height: greenFrame.height },
371
+ whiteFrame: { x: boxX.toFixed(2), y: boxY.toFixed(2), width: boxWidth.toFixed(2), height: boxHeight.toFixed(2) },
372
+ note: "Image in cover mode - white frame = same position/size as green frame, same content",
373
+ });
495
374
 
496
- // ✅ REFACTORISATION : Initialiser le crop box avec les dimensions d'affichage réelles
497
- // ✅ CRITICAL FIX: If camera frame data exists, use it to match green frame exactly
498
- const initializeCropBox = () => {
499
- // CRITICAL: Ensure displayedContentRect is available
500
- let contentRect = displayedContentRect.current;
501
- const layout = displayedImageLayout.current;
375
+ setPoints(newPoints);
376
+ hasInitializedCropBox.current = true; // ✅ CRITICAL: Mark as initialized
377
+ // CRITICAL: DO NOT nullify cameraFrameData here - keep it for Image.getSize() callback check
378
+ // It will be cleared when loading a new image
379
+ return;
380
+ }
502
381
 
503
- // Recalculate if not available
504
- if (contentRect.width === 0 || contentRect.height === 0) {
505
- if (layout.width > 0 && layout.height > 0) {
506
- updateDisplayedContentRect(layout.width, layout.height);
507
- contentRect = displayedContentRect.current;
508
- }
509
- // If still not available, use layout as fallback
510
- if (contentRect.width === 0 || contentRect.height === 0) {
511
- if (layout.width > 0 && layout.height > 0) {
512
- contentRect = {
513
- x: layout.x,
514
- y: layout.y,
515
- width: layout.width,
516
- height: layout.height
517
- };
518
- } else {
519
- console.warn("Cannot initialize crop box: displayed dimensions are zero");
520
- return;
521
- }
522
- }
382
+ // PRIORITY RULE #3: DEFAULT logic ONLY for gallery images (NOT camera)
383
+ // If we reach here and imageSource is 'camera', something went wrong
384
+ if (imageSource.current === 'camera') {
385
+ console.warn("⚠️ Camera image but no greenFrame found - this should not happen");
386
+ return;
523
387
  }
524
388
 
525
- const { x, y, width, height } = contentRect;
526
-
527
- // PRIORITY: If we have green frame data from camera, use it to match exactly
528
- if (cameraFrameData.current && cameraFrameData.current.greenFrame && originalImageDimensions.current.width > 0) {
529
- const converted = convertGreenFrameToImageCoords(
530
- cameraFrameData.current.greenFrame,
531
- cameraFrameData.current.capturedImageSize || originalImageDimensions.current,
532
- contentRect
533
- );
534
-
535
- if (converted && converted.displayCoords) {
536
- const { x: boxX, y: boxY, width: boxWidth, height: boxHeight } = converted.displayCoords;
537
- const { x: contentX, y: contentY, width: contentWidth, height: contentHeight } = contentRect;
538
-
539
- // ✅ CRITICAL: Clamp points to displayed image bounds (double-check)
540
- // Ensure the bounding box stays within contentRect, but preserve aspect ratio and percentage
541
- // First, clamp position
542
- let clampedBoxX = Math.max(contentX, Math.min(boxX, contentX + contentWidth - boxWidth));
543
- let clampedBoxY = Math.max(contentY, Math.min(boxY, contentY + contentHeight - boxHeight));
544
-
545
- // ✅ CRITICAL: Preserve the width and height from conversion (they should already be correct)
546
- // Only adjust if they would exceed bounds, but try to maintain the 80% coverage
547
- let clampedBoxWidth = boxWidth;
548
- let clampedBoxHeight = boxHeight;
549
-
550
- // Ensure the box fits within contentRect
551
- if (clampedBoxX + clampedBoxWidth > contentX + contentWidth) {
552
- clampedBoxWidth = contentX + contentWidth - clampedBoxX;
553
- }
554
- if (clampedBoxY + clampedBoxHeight > contentY + contentHeight) {
555
- clampedBoxHeight = contentY + contentHeight - clampedBoxY;
556
- }
557
-
558
- // ✅ CRITICAL: If clamping reduced dimensions, adjust position to center the box
559
- // This ensures the white bounding box maintains the same visual percentage as the green frame
560
- if (clampedBoxWidth < boxWidth || clampedBoxHeight < boxHeight) {
561
- // Re-center if possible
562
- const idealX = contentX + (contentWidth - clampedBoxWidth) / 2;
563
- const idealY = contentY + (contentHeight - clampedBoxHeight) / 2;
564
- clampedBoxX = Math.max(contentX, Math.min(idealX, contentX + contentWidth - clampedBoxWidth));
565
- clampedBoxY = Math.max(contentY, Math.min(idealY, contentY + contentHeight - clampedBoxHeight));
566
- }
567
-
568
- // ✅ CRITICAL: Ensure points are within contentRect bounds but not exactly at the edges
569
- // This allows free movement in all directions
570
- const minX = contentX;
571
- const maxX = contentX + contentWidth;
572
- const minY = contentY;
573
- const maxY = contentY + contentHeight;
574
-
575
- // Create points from the clamped green frame coordinates
576
- // Clamp each point individually to ensure they're within bounds
577
- const newPoints = [
578
- {
579
- x: Math.max(minX, Math.min(clampedBoxX, maxX)),
580
- y: Math.max(minY, Math.min(clampedBoxY, maxY))
581
- },
582
- {
583
- x: Math.max(minX, Math.min(clampedBoxX + clampedBoxWidth, maxX)),
584
- y: Math.max(minY, Math.min(clampedBoxY, maxY))
585
- },
586
- {
587
- x: Math.max(minX, Math.min(clampedBoxX + clampedBoxWidth, maxX)),
588
- y: Math.max(minY, Math.min(clampedBoxY + clampedBoxHeight, maxY))
589
- },
590
- {
591
- x: Math.max(minX, Math.min(clampedBoxX, maxX)),
592
- y: Math.max(minY, Math.min(clampedBoxY + clampedBoxHeight, maxY))
593
- },
594
- ];
595
-
596
- // ✅ VALIDATION: Verify the white bounding box matches green frame percentage (80%)
597
- const whiteBoxPercentOfDisplay = {
598
- width: (clampedBoxWidth / contentRect.width) * 100,
599
- height: (clampedBoxHeight / contentRect.height) * 100
600
- };
601
- const greenFramePercentOfWrapper = {
602
- width: (cameraFrameData.current.greenFrame.width / cameraFrameData.current.greenFrame.wrapperWidth) * 100,
603
- height: (cameraFrameData.current.greenFrame.height / cameraFrameData.current.greenFrame.wrapperHeight) * 100
604
- };
605
-
606
- // ✅ DEBUG: Log if percentages don't match (should both be ~80%)
607
- if (Math.abs(whiteBoxPercentOfDisplay.width - greenFramePercentOfWrapper.width) > 5 ||
608
- Math.abs(whiteBoxPercentOfDisplay.height - greenFramePercentOfWrapper.height) > 5) {
609
- console.warn("⚠️ White box percentage doesn't match green frame:", {
610
- whiteBox: whiteBoxPercentOfDisplay,
611
- greenFrame: greenFramePercentOfWrapper,
612
- difference: {
613
- width: Math.abs(whiteBoxPercentOfDisplay.width - greenFramePercentOfWrapper.width),
614
- height: Math.abs(whiteBoxPercentOfDisplay.height - greenFramePercentOfWrapper.height)
615
- }
616
- });
617
- }
618
-
619
- console.log("✅ Initializing crop box from green frame (clamped):", {
620
- greenFrame: cameraFrameData.current.greenFrame,
621
- converted,
622
- clamped: {
623
- x: clampedBoxX,
624
- y: clampedBoxY,
625
- width: clampedBoxWidth,
626
- height: clampedBoxHeight
627
- },
628
- contentRect: contentRect,
629
- points: newPoints,
630
- validation: {
631
- whiteBoxPercentOfDisplay,
632
- greenFramePercentOfWrapper,
633
- shouldMatch: "White box % should match green frame % (80% - accounting for aspect ratio)"
634
- }
635
- });
636
-
637
- setPoints(newPoints);
638
- // Clear camera frame data after use to avoid reusing it
639
- cameraFrameData.current = null;
640
- return;
641
- }
389
+ // DEFAULT: Crop box (70% of VISIBLE IMAGE AREA - centered) - ONLY for gallery images
390
+ const boxWidth = imageRect.width * 0.70; // 70% of visible image width
391
+ const boxHeight = imageRect.height * 0.70; // 70% of visible image height
392
+ const boxX = imageRectX + (imageRect.width - boxWidth) / 2; // Centered in image area
393
+ const boxY = imageRectY + (imageRect.height - boxHeight) / 2; // Centered in image area
394
+
395
+ // SAFETY: Validate calculated coordinates before creating points
396
+ const isValidCoordinate = (val) => typeof val === 'number' && isFinite(val) && !isNaN(val);
397
+
398
+ if (!isValidCoordinate(boxX) || !isValidCoordinate(boxY) ||
399
+ !isValidCoordinate(boxWidth) || !isValidCoordinate(boxHeight)) {
400
+ console.warn("⚠️ Invalid coordinates calculated for default crop box, skipping initialization");
401
+ return;
642
402
  }
643
403
 
644
- // ✅ CRITICAL: Default crop box (70% of displayed area - centered)
645
- const boxWidth = width * 0.70; // 70% width
646
- const boxHeight = height * 0.70; // 70% height
647
- const boxX = x + (width - boxWidth) / 2; // Centered horizontally
648
- const boxY = y + (height - boxHeight) / 2; // Centered vertically
649
404
  const newPoints = [
650
- { x: boxX, y: boxY }, // Top-left
651
- { x: boxX + boxWidth, y: boxY }, // Top-right
652
- { x: boxX + boxWidth, y: boxY + boxHeight }, // Bottom-right
653
- { x: boxX, y: boxY + boxHeight }, // Bottom-left
405
+ { x: boxX, y: boxY },
406
+ { x: boxX + boxWidth, y: boxY },
407
+ { x: boxX + boxWidth, y: boxY + boxHeight },
408
+ { x: boxX, y: boxY + boxHeight },
654
409
  ];
655
410
 
656
- console.log("Initializing crop box (default - 70% centered):", {
657
- displayedWidth: width,
658
- displayedHeight: height,
659
- boxWidth,
660
- boxHeight,
661
- boxX,
662
- boxY,
411
+ // SAFETY: Validate all points before setting
412
+ const validPoints = newPoints.filter(p => isValidCoordinate(p.x) && isValidCoordinate(p.y));
413
+ if (validPoints.length !== newPoints.length) {
414
+ console.warn("⚠️ Some points have invalid coordinates in default crop box, skipping initialization");
415
+ return;
416
+ }
417
+
418
+ console.log("✅ Initializing crop box (default - 70% of visible image area, gallery only):", {
419
+ wrapper: { width: wrapper.width, height: wrapper.height },
420
+ imageDisplayRect: imageRect,
421
+ boxInImage: { x: boxX, y: boxY, width: boxWidth, height: boxHeight },
663
422
  points: newPoints
664
423
  });
665
424
 
666
425
  setPoints(newPoints);
426
+ hasInitializedCropBox.current = true; // ✅ CRITICAL: Mark as initialized
427
+ };
428
+
429
+ // ✅ RÉFÉRENTIEL UNIQUE : Callback pour mettre à jour le layout du wrapper commun
430
+ // Ce wrapper a exactement les mêmes dimensions que le wrapper de CustomCamera (9/16, width = screenWidth)
431
+ const onCommonWrapperLayout = (e) => {
432
+ const layout = e.nativeEvent.layout;
433
+ commonWrapperLayout.current = {
434
+ x: layout.x,
435
+ y: layout.y,
436
+ width: layout.width,
437
+ height: layout.height
438
+ };
439
+
440
+ console.log("✅ Common wrapper layout updated:", commonWrapperLayout.current);
441
+
442
+ // ✅ Recalculer imageDisplayRect dès que le wrapper est prêt
443
+ if (originalImageDimensions.current.width > 0 && originalImageDimensions.current.height > 0) {
444
+ updateImageDisplayRect(layout.width, layout.height);
445
+
446
+ // ✅ CRITICAL FIX #2: Initialize crop box ONLY if not already initialized
447
+ // For camera images: initialize ONLY from greenFrame (already done when cameraFrameData was set)
448
+ if (!hasInitializedCropBox.current && points.length === 0) {
449
+ // ✅ CRITICAL: Only initialize for gallery images here
450
+ // Camera images should be initialized when cameraFrameData is set, not here
451
+ if (imageSource.current !== 'camera') {
452
+ initializeCropBox();
453
+ }
454
+ }
455
+ }
667
456
  };
668
457
 
669
458
  // ✅ REFACTORISATION : Mettre à jour les dimensions d'affichage et les dimensions pour SVG
@@ -686,9 +475,27 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage ,addheight}) =>
686
475
  height: layout.height
687
476
  };
688
477
 
689
- // ✅ CORRECTION: recalculer contentRect (contain). Si les dimensions originales ne sont pas encore connues,
690
- // updateDisplayedContentRect va fallback et sera recalculé quand Image.getSize arrivera.
691
- updateDisplayedContentRect(layout.width, layout.height);
478
+ // ✅ Si l'image vient de la caméra et que les dimensions originales ne sont pas encore définies,
479
+ // les initialiser à partir de cameraFrameData AVANT de calculer le contentRect.
480
+ if (
481
+ originalImageDimensions.current.width === 0 &&
482
+ cameraFrameData.current &&
483
+ cameraFrameData.current.capturedImageSize
484
+ ) {
485
+ const { width, height } = cameraFrameData.current.capturedImageSize;
486
+ originalImageDimensions.current = { width, height };
487
+ console.log("✅ originalImageDimensions initialisées depuis cameraFrameData dans onImageLayout:", {
488
+ width,
489
+ height,
490
+ });
491
+ }
492
+
493
+ // ✅ RÉFÉRENTIEL UNIQUE : Recalculer imageDisplayRect dans le wrapper commun
494
+ // Si le wrapper commun n'est pas encore prêt, on attendra onCommonWrapperLayout
495
+ const wrapper = commonWrapperLayout.current;
496
+ if (wrapper.width > 0 && wrapper.height > 0) {
497
+ updateImageDisplayRect(wrapper.width, wrapper.height);
498
+ }
692
499
 
693
500
  console.log("Displayed image layout updated:", {
694
501
  width: layout.width,
@@ -697,27 +504,25 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage ,addheight}) =>
697
504
  y: layout.y
698
505
  });
699
506
 
700
- // ✅ CRITICAL FIX: Only initialize crop box if:
701
- // 1. Layout dimensions are available
702
- // 2. We have no points yet (first initialization)
703
- // 3. We have original image dimensions (either from camera or Image.getSize)
704
- // This prevents initializing with wrong dimensions for subsequent images
705
- if (layout.width > 0 && layout.height > 0 && points.length === 0) {
706
- // CRITICAL: Wait for original dimensions before initializing
707
- // If dimensions not available yet, initializeCropBox will be called from useEffect when Image.getSize completes
708
- if (originalImageDimensions.current.width > 0 && originalImageDimensions.current.height > 0) {
507
+ // ✅ CRITICAL FIX #2: Do NOT initialize crop box in onImageLayout for camera images
508
+ // Camera images should be initialized ONLY when cameraFrameData is set (in useEffect)
509
+ // Gallery images can be initialized here if not already done
510
+ if (
511
+ wrapper.width > 0 &&
512
+ wrapper.height > 0 &&
513
+ layout.width > 0 &&
514
+ layout.height > 0 &&
515
+ !hasInitializedCropBox.current &&
516
+ points.length === 0 &&
517
+ originalImageDimensions.current.width > 0 &&
518
+ originalImageDimensions.current.height > 0
519
+ ) {
520
+ // ✅ CRITICAL: Only initialize for gallery images here
521
+ // Camera images should be initialized when cameraFrameData is set, not here
522
+ if (imageSource.current !== 'camera') {
709
523
  initializeCropBox();
710
- } else if (cameraFrameData.current && cameraFrameData.current.capturedImageSize) {
711
- // ✅ If we have camera dimensions, use them immediately
712
- originalImageDimensions.current = {
713
- width: cameraFrameData.current.capturedImageSize.width,
714
- height: cameraFrameData.current.capturedImageSize.height,
715
- };
716
- initializeCropBox();
717
- } else {
718
- // ✅ For gallery images, we can initialize with layout dimensions as fallback
719
- // The crop box will be recalculated when Image.getSize completes
720
- // But we initialize now so the user sees a border immediately
524
+ } else if (cameraFrameData.current && cameraFrameData.current.greenFrame) {
525
+ // ✅ For camera images, initialize ONLY if greenFrame is available
721
526
  initializeCropBox();
722
527
  }
723
528
  }
@@ -730,85 +535,184 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage ,addheight}) =>
730
535
  return path + 'Z';
731
536
  };
732
537
 
538
+ // ✅ Helper function: Find closest point on a line segment to a tap point
539
+ const findClosestPointOnLine = (tapX, tapY, lineStartX, lineStartY, lineEndX, lineEndY) => {
540
+ const dx = lineEndX - lineStartX;
541
+ const dy = lineEndY - lineStartY;
542
+ const lengthSquared = dx * dx + dy * dy;
543
+
544
+ if (lengthSquared === 0) {
545
+ // Line segment is a point
546
+ return { x: lineStartX, y: lineStartY, distance: Math.sqrt((tapX - lineStartX) ** 2 + (tapY - lineStartY) ** 2) };
547
+ }
548
+
549
+ // Calculate projection parameter t (0 to 1)
550
+ const t = Math.max(0, Math.min(1, ((tapX - lineStartX) * dx + (tapY - lineStartY) * dy) / lengthSquared));
551
+
552
+ // Calculate closest point on line segment
553
+ const closestX = lineStartX + t * dx;
554
+ const closestY = lineStartY + t * dy;
555
+
556
+ // Calculate distance from tap to closest point
557
+ const distance = Math.sqrt((tapX - closestX) ** 2 + (tapY - closestY) ** 2);
558
+
559
+ return { x: closestX, y: closestY, distance, t };
560
+ };
561
+
562
+ // ✅ Helper function: Check if tap is near any line segment and find closest point
563
+ const findClosestPointOnFrame = (tapX, tapY, lineTolerance = 30) => {
564
+ if (points.length < 2) return null;
565
+
566
+ let closestPoint = null;
567
+ let minDistance = Infinity;
568
+ let insertIndex = -1;
569
+
570
+ // Check each line segment (closed polygon: last point connects to first)
571
+ for (let i = 0; i < points.length; i++) {
572
+ const start = points[i];
573
+ const end = points[(i + 1) % points.length];
574
+
575
+ const result = findClosestPointOnLine(tapX, tapY, start.x, start.y, end.x, end.y);
576
+
577
+ if (result.distance < minDistance && result.distance < lineTolerance) {
578
+ minDistance = result.distance;
579
+ closestPoint = { x: result.x, y: result.y };
580
+ // Insert after the start point of this segment
581
+ insertIndex = i + 1;
582
+ }
583
+ }
584
+
585
+ return closestPoint ? { point: closestPoint, insertIndex } : null;
586
+ };
587
+
733
588
  const handleTap = (e) => {
734
589
  if (!image || showResult) return;
735
590
  const now = Date.now();
736
591
  const { locationX: tapX, locationY: tapY } = e.nativeEvent;
737
592
 
738
- // ✅ CRITICAL: Ensure displayedContentRect is available
739
- let contentRect = displayedContentRect.current;
740
- const layout = displayedImageLayout.current;
593
+ // ✅ RÉFÉRENTIEL UNIQUE : Utiliser imageDisplayRect (zone réelle de l'image dans le wrapper)
594
+ // Les coordonnées du tap sont relatives au wrapper commun
595
+ let imageRect = imageDisplayRect.current;
596
+ const wrapper = commonWrapperLayout.current;
741
597
 
742
598
  // Recalculate if not available
743
- if (contentRect.width === 0 || contentRect.height === 0) {
744
- if (layout.width > 0 && layout.height > 0) {
745
- updateDisplayedContentRect(layout.width, layout.height);
746
- contentRect = displayedContentRect.current;
599
+ if (imageRect.width === 0 || imageRect.height === 0) {
600
+ if (wrapper.width > 0 && wrapper.height > 0 && originalImageDimensions.current.width > 0) {
601
+ updateImageDisplayRect(wrapper.width, wrapper.height);
602
+ imageRect = imageDisplayRect.current;
747
603
  }
748
- // If still not available, use layout as fallback
749
- if (contentRect.width === 0 || contentRect.height === 0) {
750
- if (layout.width > 0 && layout.height > 0) {
751
- contentRect = {
752
- x: layout.x,
753
- y: layout.y,
754
- width: layout.width,
755
- height: layout.height
604
+ // If still not available, use wrapper as fallback
605
+ if (imageRect.width === 0 || imageRect.height === 0) {
606
+ if (wrapper.width > 0 && wrapper.height > 0) {
607
+ imageRect = {
608
+ x: wrapper.x,
609
+ y: wrapper.y,
610
+ width: wrapper.width,
611
+ height: wrapper.height
756
612
  };
757
613
  } else {
758
- console.warn("⚠️ Cannot handle tap: no layout dimensions available");
614
+ console.warn("⚠️ Cannot handle tap: wrapper or imageDisplayRect not available");
759
615
  return;
760
616
  }
761
617
  }
762
618
  }
763
-
764
- // ✅ Clamp to real displayed image content (avoid points outside image due to letterboxing)
765
- const { x: cx, y: cy, width: cw, height: ch } = contentRect;
766
- const boundedTapX = Math.max(cx, Math.min(tapX, cx + cw));
767
- const boundedTapY = Math.max(cy, Math.min(tapY, cy + ch));
768
-
769
- const selectRadius = 28; // easier to grab points
770
- if (lastTap.current && now - lastTap.current < 300) {
771
- const exists = points.some(p => Math.abs(p.x - boundedTapX) < selectRadius && Math.abs(p.y - boundedTapY) < selectRadius);
772
- if (!exists) setPoints([...points, { x: boundedTapX, y: boundedTapY }]);
773
- lastTap.current = null;
619
+
620
+ // ✅ Clamp to real displayed image content (imageDisplayRect dans le wrapper)
621
+ // Les coordonnées tapX/tapY sont relatives au wrapper commun
622
+ const imageRectX = wrapper.x + imageRect.x;
623
+ const imageRectY = wrapper.y + imageRect.y;
624
+ const { x: cx, y: cy, width: cw, height: ch } = {
625
+ x: imageRectX,
626
+ y: imageRectY,
627
+ width: imageRect.width,
628
+ height: imageRect.height
629
+ };
630
+ // ✅ Larger select radius for easier point selection (especially on touch screens)
631
+ const selectRadius = 50; // Increased from 28 to 50 for better UX
632
+
633
+ // ✅ CRITICAL: Check for existing point selection FIRST (using raw tap coordinates)
634
+ // Don't clamp tapX/Y for point selection - points can be anywhere in wrapper now
635
+ const index = points.findIndex(p => Math.abs(p.x - tapX) < selectRadius && Math.abs(p.y - tapY) < selectRadius);
636
+
637
+ if (index !== -1) {
638
+ // ✅ Point found - select it for dragging
639
+ selectedPointIndex.current = index;
640
+
641
+ // Store initial positions
642
+ initialTouchPosition.current = { x: tapX, y: tapY };
643
+ lastTouchPosition.current = { x: tapX, y: tapY };
644
+ initialPointPosition.current = { ...points[index] };
645
+ lastValidPosition.current = { ...points[index] };
646
+
647
+ // Calculate offset between point and touch at drag start
648
+ touchOffset.current = {
649
+ x: points[index].x - tapX,
650
+ y: points[index].y - tapY
651
+ };
652
+
653
+ console.log("🎯 DRAG START - Offset calculated:", {
654
+ pointX: points[index].x.toFixed(2),
655
+ pointY: points[index].y.toFixed(2),
656
+ touchX: tapX.toFixed(2),
657
+ touchY: tapY.toFixed(2),
658
+ offsetX: touchOffset.current.x.toFixed(2),
659
+ offsetY: touchOffset.current.y.toFixed(2)
660
+ });
661
+
662
+ // Disable parent ScrollView scrolling when dragging
663
+ try {
664
+ const findScrollView = (node) => {
665
+ if (!node) return null;
666
+ if (node._component && node._component.setNativeProps) {
667
+ node._component.setNativeProps({ scrollEnabled: false });
668
+ }
669
+ return findScrollView(node._owner || node._parent);
670
+ };
671
+ } catch (e) {
672
+ // Ignore errors
673
+ }
774
674
  } else {
775
- const index = points.findIndex(p => Math.abs(p.x - boundedTapX) < selectRadius && Math.abs(p.y - boundedTapY) < selectRadius);
776
- if (index !== -1) {
777
- selectedPointIndex.current = index;
778
- // FREE DRAG: Store initial positions for delta-based movement
779
- initialTouchPosition.current = { x: tapX, y: tapY };
780
- lastTouchPosition.current = { x: tapX, y: tapY }; // Store last touch for incremental delta
781
- initialPointPosition.current = { ...points[index] };
782
- lastValidPosition.current = { ...points[index] }; // store original position before move
675
+ // No point found - check if double-tap on a line to create new point
676
+ const isDoubleTap = lastTap.current && now - lastTap.current < 300;
677
+
678
+ if (isDoubleTap && points.length >= 2) {
679
+ // Find closest point on frame lines
680
+ const lineResult = findClosestPointOnFrame(tapX, tapY, 30); // 30px tolerance
783
681
 
784
- // CRITICAL: Disable parent ScrollView scrolling when dragging a point
785
- // This prevents ScrollView from intercepting vertical movement
786
- try {
787
- // Find and disable parent ScrollView if it exists
788
- const findScrollView = (node) => {
789
- if (!node) return null;
790
- if (node._component && node._component.setNativeProps) {
791
- // Try to disable scrolling
792
- node._component.setNativeProps({ scrollEnabled: false });
793
- }
794
- return findScrollView(node._owner || node._parent);
795
- };
796
- // Note: This is a workaround - ideally we'd pass a ref to disable scroll
797
- } catch (e) {
798
- // Ignore errors
682
+ if (lineResult) {
683
+ const { point, insertIndex } = lineResult;
684
+
685
+ // Check if a point already exists very close to this position
686
+ const exists = points.some(p => Math.abs(p.x - point.x) < selectRadius && Math.abs(p.y - point.y) < selectRadius);
687
+
688
+ if (!exists) {
689
+ // Insert new point at the correct position in the polygon
690
+ const newPoints = [...points];
691
+ newPoints.splice(insertIndex, 0, point);
692
+ setPoints(newPoints);
693
+
694
+ console.log("✅ New point created on frame line:", {
695
+ tap: { x: tapX.toFixed(2), y: tapY.toFixed(2) },
696
+ newPoint: { x: point.x.toFixed(2), y: point.y.toFixed(2) },
697
+ insertIndex,
698
+ totalPoints: newPoints.length
699
+ });
700
+
701
+ lastTap.current = null; // Reset to prevent triple-tap
702
+ return;
703
+ }
799
704
  }
800
705
  }
801
- lastTap.current = now;
802
706
  }
707
+
708
+ lastTap.current = now;
803
709
  };
804
710
 
805
711
  const handleMove = (e) => {
806
712
  if (showResult || selectedPointIndex.current === null) return;
807
713
 
808
714
  // ✅ FREE DRAG: Use delta-based movement for smooth, unconstrained dragging
809
- // ✅ CRITICAL FIX: Use incremental delta calculation for more reliable vertical movement
810
- // Instead of calculating delta from initial position, calculate from last position
811
- // This works better when ScrollView intercepts some events
715
+
812
716
  const nativeEvent = e.nativeEvent;
813
717
  const currentX = nativeEvent.locationX;
814
718
  const currentY = nativeEvent.locationY;
@@ -822,7 +726,6 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage ,addheight}) =>
822
726
  return;
823
727
  }
824
728
 
825
- // ✅ CRITICAL: Use incremental delta (from last position) instead of absolute delta
826
729
  // This is more reliable when ScrollView affects coordinate updates
827
730
  let deltaX, deltaY;
828
731
  if (lastTouchPosition.current) {
@@ -837,138 +740,270 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage ,addheight}) =>
837
740
  console.warn("⚠️ No touch position reference available");
838
741
  return;
839
742
  }
840
-
841
- // CRITICAL: Don't update lastTouchPosition here - update it AFTER clamping
842
- // This ensures that if the point was clamped, lastTouchPosition reflects the actual
843
- // touch position, allowing the next delta to be calculated correctly
844
-
845
- // ✅ CRITICAL: Ensure displayedContentRect is available
846
- let contentRect = displayedContentRect.current;
847
- const layout = displayedImageLayout.current;
743
+
744
+ // Les coordonnées de mouvement sont relatives au wrapper commun
745
+ let imageRect = imageDisplayRect.current;
746
+ const wrapper = commonWrapperLayout.current;
848
747
 
849
748
  // Recalculate if not available
850
- if (contentRect.width === 0 || contentRect.height === 0) {
851
- if (layout.width > 0 && layout.height > 0) {
852
- updateDisplayedContentRect(layout.width, layout.height);
853
- contentRect = displayedContentRect.current;
749
+ if (imageRect.width === 0 || imageRect.height === 0) {
750
+ if (wrapper.width > 0 && wrapper.height > 0 && originalImageDimensions.current.width > 0) {
751
+ updateImageDisplayRect(wrapper.width, wrapper.height);
752
+ imageRect = imageDisplayRect.current;
854
753
  }
855
- // If still not available, use layout as fallback
856
- if (contentRect.width === 0 || contentRect.height === 0) {
857
- if (layout.width > 0 && layout.height > 0) {
858
- contentRect = {
859
- x: layout.x,
860
- y: layout.y,
861
- width: layout.width,
862
- height: layout.height
754
+ // If still not available, use wrapper as fallback
755
+ if (imageRect.width === 0 || imageRect.height === 0) {
756
+ if (wrapper.width > 0 && wrapper.height > 0) {
757
+ imageRect = {
758
+ x: wrapper.x,
759
+ y: wrapper.y,
760
+ width: wrapper.width,
761
+ height: wrapper.height
863
762
  };
864
763
  } else {
865
- console.warn("⚠️ Cannot move point: no layout dimensions available");
764
+ console.warn("⚠️ Cannot move point: wrapper or imageDisplayRect not available");
866
765
  return;
867
766
  }
868
767
  }
869
768
  }
870
769
 
770
+ // ✅ CRITICAL: Calculate absolute bounds of imageDisplayRect within the wrapper
771
+ // imageRect is relative to wrapper, so we need to add wrapper offset
772
+ const imageRectX = wrapper.x + imageRect.x;
773
+ const imageRectY = wrapper.y + imageRect.y;
774
+ const contentRect = {
775
+ x: imageRectX,
776
+ y: imageRectY,
777
+ width: imageRect.width,
778
+ height: imageRect.height
779
+ };
780
+
871
781
  // ✅ FREE DRAG: Ensure initial positions are set
872
782
  if (!initialPointPosition.current) {
873
783
  const currentPoint = points[selectedPointIndex.current];
874
- if (currentPoint) {
784
+ if (currentPoint && typeof currentPoint.x === 'number' && typeof currentPoint.y === 'number') {
875
785
  initialPointPosition.current = { ...currentPoint };
876
786
  } else {
877
- console.warn("⚠️ No point found for selected index");
787
+ console.warn("⚠️ No point found for selected index or invalid point data");
878
788
  return;
879
789
  }
880
790
  }
881
791
 
882
- // ✅ CRITICAL: deltaX and deltaY are already calculated above using incremental approach
883
- // This ensures smooth movement even when ScrollView affects coordinate updates
884
-
885
- // CRITICAL FIX: Use CURRENT point position (after previous clamping) instead of initial position
886
- // This allows the point to move even if it was previously clamped to a limit
887
- // If lastValidPosition exists (from previous clamping), use it; otherwise use initial position
888
- const basePoint = lastValidPosition.current || initialPointPosition.current;
889
-
890
- // ✅ DEBUG: Log movement to identify vertical movement issues
891
- if (Math.abs(deltaY) > 10) {
892
- console.log("🔄 Movement detected:", {
893
- deltaX: deltaX.toFixed(2),
894
- deltaY: deltaY.toFixed(2),
895
- currentY: currentY.toFixed(2),
896
- initialY: initialTouchPosition.current?.y?.toFixed(2),
897
- pointInitialY: initialPointPosition.current.y.toFixed(2),
898
- basePointY: basePoint.y.toFixed(2),
899
- usingLastValid: !!lastValidPosition.current
900
- });
792
+ // ✅ NEW APPROACH: Use touchOffset to map touch position directly to point position
793
+ // This eliminates delta accumulation and "dead zone" issues completely
794
+ if (!touchOffset.current) {
795
+ console.warn("⚠️ touchOffset not initialized, cannot move point");
796
+ return;
901
797
  }
902
798
 
903
- // ✅ FREE DRAG: Apply delta to CURRENT point position (not initial)
904
- // - This allows movement even if point was previously clamped
905
- // - No constraints on distance, angle, or edge length
906
- // - No forced horizontal/vertical alignment
907
- // - No angle locking
908
- // - Direct mapping of gesture delta (dx, dy) - both X and Y treated equally
909
- const newX = basePoint.x + deltaX;
910
- const newY = basePoint.y + deltaY;
799
+ // ✅ DIRECT MAPPING: newPosition = touchPosition + offset
800
+ // No delta accumulation, no zone morte
801
+ const newX = currentX + touchOffset.current.x;
802
+ const newY = currentY + touchOffset.current.y;
911
803
 
912
- // ✅ ONLY CONSTRAINT: Clamp to image bounds to keep points inside the image
913
- // x ∈ [cx, cx + cw], y ∈ [cy, cy + ch]
914
- // This is the ONLY constraint - no other geometry normalization
804
+ // ✅ SEPARATE DRAG BOUNDS vs CROP BOUNDS
915
805
  const { x: cx, y: cy, width: cw, height: ch } = contentRect;
916
- // ✅ CRITICAL: Calculate bounds correctly - maxX = cx + cw, maxY = cy + ch
917
- const maxX = cx + cw;
918
- const maxY = cy + ch;
919
- const boundedX = Math.max(cx, Math.min(newX, maxX));
920
- const boundedY = Math.max(cy, Math.min(newY, maxY));
921
806
 
922
- // ✅ DEBUG: Log bounds calculation to verify clamping is correct
923
- if (Math.abs(newY - boundedY) > 1) {
924
- console.log("🔍 Y coordinate clamping:", {
925
- newY: newY.toFixed(2),
926
- boundedY: boundedY.toFixed(2),
927
- cy: cy.toFixed(2),
928
- maxY: maxY.toFixed(2),
929
- ch: ch.toFixed(2),
930
- deltaY: deltaY.toFixed(2),
931
- isAtMaxLimit: Math.abs(boundedY - maxY) < 0.01,
932
- isAtMinLimit: Math.abs(boundedY - cy) < 0.01
807
+ // ✅ STRICT BOUNDS: For final crop safety (imageDisplayRect)
808
+ const strictMinX = cx;
809
+ const strictMaxX = cx + cw;
810
+ const strictMinY = cy;
811
+ const strictMaxY = cy + ch;
812
+
813
+ // ✅ DRAG BOUNDS: Allow movement ANYWHERE in wrapper during drag
814
+ // Points can move freely across the entire screen for maximum flexibility
815
+ // They will be clamped to imageDisplayRect only on release for safe cropping
816
+ const wrapperRect = wrapper;
817
+ const overshootMinX = wrapperRect.x;
818
+ const overshootMaxX = wrapperRect.x + wrapperRect.width;
819
+ const overshootMinY = wrapperRect.y;
820
+ const overshootMaxY = wrapperRect.y + wrapperRect.height;
821
+
822
+ // ✅ DRAG BOUNDS: Clamp ONLY to overshootBounds during drag (NOT strictBounds)
823
+ const dragX = Math.max(overshootMinX, Math.min(newX, overshootMaxX));
824
+ const dragY = Math.max(overshootMinY, Math.min(newY, overshootMaxY));
825
+
826
+ // ✅ UPDATE POINT: Use drag bounds (overshoot) - allows visual freedom
827
+ const updatedPoint = { x: dragX, y: dragY };
828
+
829
+ // ✅ CRITICAL: Detect if point is AT overshoot boundary (not just clamped)
830
+ // Check if point is exactly at overshootMin/Max (within 1px tolerance)
831
+ const isAtOvershootMinX = Math.abs(dragX - overshootMinX) < 1;
832
+ const isAtOvershootMaxX = Math.abs(dragX - overshootMaxX) < 1;
833
+ const isAtOvershootMinY = Math.abs(dragY - overshootMinY) < 1;
834
+ const isAtOvershootMaxY = Math.abs(dragY - overshootMaxY) < 1;
835
+
836
+ const isAtBoundaryX = isAtOvershootMinX || isAtOvershootMaxX;
837
+ const isAtBoundaryY = isAtOvershootMinY || isAtOvershootMaxY;
838
+
839
+ // Only recalculate offset when FIRST hitting boundary (transition free → boundary)
840
+ const justHitBoundaryX = isAtBoundaryX && !wasClampedLastFrame.current.x;
841
+ const justHitBoundaryY = isAtBoundaryY && !wasClampedLastFrame.current.y;
842
+
843
+ if (justHitBoundaryX || justHitBoundaryY) {
844
+ // Point JUST hit overshoot boundary - recalculate offset once
845
+ const newOffsetX = justHitBoundaryX ? (dragX - currentX) : touchOffset.current.x;
846
+ const newOffsetY = justHitBoundaryY ? (dragY - currentY) : touchOffset.current.y;
847
+
848
+ touchOffset.current = {
849
+ x: newOffsetX,
850
+ y: newOffsetY
851
+ };
852
+
853
+ console.log("✅ OFFSET RECALCULATED (hit boundary):", {
854
+ axis: justHitBoundaryX ? 'X' : 'Y',
855
+ touchY: currentY.toFixed(2),
856
+ dragY: dragY.toFixed(2),
857
+ newOffsetY: touchOffset.current.y.toFixed(2),
858
+ note: "First contact with boundary - offset locked"
933
859
  });
934
860
  }
935
861
 
936
- // DEBUG: Log if clamping is limiting movement
937
- if (Math.abs(newY - boundedY) > 1 || Math.abs(newX - boundedX) > 1) {
938
- console.log("⚠️ Movement clamped:", {
939
- requested: { x: newX.toFixed(2), y: newY.toFixed(2) },
940
- clamped: { x: boundedX.toFixed(2), y: boundedY.toFixed(2) },
941
- contentRect: { x: cx.toFixed(2), y: cy.toFixed(2), width: cw.toFixed(2), height: ch.toFixed(2) }
862
+ // Update boundary state for next frame
863
+ wasClampedLastFrame.current = { x: isAtBoundaryX, y: isAtBoundaryY };
864
+
865
+ // DEBUG: Log when in overshoot zone (only when not at boundary)
866
+ const isInOvershootY = dragY < strictMinY || dragY > strictMaxY;
867
+ if (isInOvershootY && !isAtBoundaryY) {
868
+ console.log("🎯 IN OVERSHOOT ZONE:", {
869
+ touchY: currentY.toFixed(2),
870
+ appliedY: dragY.toFixed(2),
871
+ overshootRange: `${overshootMinY.toFixed(2)} - ${overshootMaxY.toFixed(2)}`,
872
+ strictRange: `${strictMinY.toFixed(2)} - ${strictMaxY.toFixed(2)}`
942
873
  });
943
874
  }
944
-
945
- // ✅ FREE DRAG: Update point directly
946
- // - No polygon simplification
947
- // - No reordering of points
948
- // - No geometry normalization during drag
949
- const updatedPoint = { x: boundedX, y: boundedY };
950
- // ✅ CRITICAL: Always update lastValidPosition with the clamped position
951
- // This ensures that the next movement calculation uses the current (clamped) position
952
- // instead of the initial position, allowing movement even after clamping
953
- lastValidPosition.current = updatedPoint;
954
875
 
955
- // ✅ CRITICAL: Update lastTouchPosition AFTER clamping
956
- // This ensures that the next delta calculation is relative to the current touch position
957
- // If the point was clamped, this allows movement in the opposite direction on the next move
876
+ // ✅ Update lastValidPosition ONLY if point is within strictBounds
877
+ const isStrictlyValid =
878
+ dragX >= strictMinX && dragX <= strictMaxX &&
879
+ dragY >= strictMinY && dragY <= strictMaxY;
880
+
881
+ if (isStrictlyValid) {
882
+ lastValidPosition.current = updatedPoint;
883
+ }
884
+
885
+ // ✅ Update lastTouchPosition for next frame (simple tracking)
958
886
  lastTouchPosition.current = { x: currentX, y: currentY };
959
887
 
960
- setPoints(prev =>
961
- prev.map((p, i) =>
962
- i === selectedPointIndex.current ? updatedPoint : p
963
- )
964
- );
888
+ // ✅ DEBUG: Log the point update before setPoints
889
+ console.log("📍 UPDATING POINT:", {
890
+ index: selectedPointIndex.current,
891
+ newX: updatedPoint.x.toFixed(2),
892
+ newY: updatedPoint.y.toFixed(2),
893
+ touchX: currentX.toFixed(2),
894
+ touchY: currentY.toFixed(2),
895
+ offsetX: touchOffset.current.x.toFixed(2),
896
+ offsetY: touchOffset.current.y.toFixed(2)
897
+ });
898
+
899
+ setPoints(prev => {
900
+ // ✅ SAFETY: Ensure prev is a valid array
901
+ if (!Array.isArray(prev) || prev.length === 0) {
902
+ return prev;
903
+ }
904
+
905
+ const pointIndex = selectedPointIndex.current;
906
+ // ✅ SAFETY: Validate pointIndex
907
+ if (pointIndex === null || pointIndex === undefined || pointIndex < 0 || pointIndex >= prev.length) {
908
+ return prev;
909
+ }
910
+
911
+ // ✅ SAFETY: Filter out any invalid points and update the selected one
912
+ const newPoints = prev.map((p, i) => {
913
+ if (i === pointIndex) {
914
+ return updatedPoint;
915
+ }
916
+ // ✅ SAFETY: Ensure existing points are valid
917
+ if (p && typeof p.x === 'number' && typeof p.y === 'number') {
918
+ return p;
919
+ }
920
+ // If point is invalid, return a default point (shouldn't happen, but safety first)
921
+ return { x: 0, y: 0 };
922
+ });
923
+
924
+ // ✅ DEBUG: Log the state update
925
+ console.log("✅ STATE UPDATED:", {
926
+ index: pointIndex,
927
+ oldY: prev[pointIndex]?.y.toFixed(2),
928
+ newY: newPoints[pointIndex]?.y.toFixed(2),
929
+ changed: Math.abs(prev[pointIndex]?.y - newPoints[pointIndex]?.y) > 0.01
930
+ });
931
+
932
+ return newPoints;
933
+ });
965
934
  };
966
935
 
967
936
  const handleRelease = () => {
937
+ const wasDragging = selectedPointIndex.current !== null;
938
+
939
+ // ✅ CRITICAL: Reset drag state when drag ends
940
+ touchOffset.current = null;
941
+ wasClampedLastFrame.current = { x: false, y: false };
942
+
943
+ // ✅ VISUAL OVERSHOOT: Clamp points back to imageDisplayRect when drag ends
944
+ // This ensures final crop is always within valid image bounds
945
+ if (wasDragging && selectedPointIndex.current !== null) {
946
+ const wrapper = commonWrapperLayout.current;
947
+ let imageRect = imageDisplayRect.current;
948
+
949
+ // Recalculate imageDisplayRect if needed
950
+ if (imageRect.width === 0 || imageRect.height === 0) {
951
+ if (wrapper.width > 0 && wrapper.height > 0 && originalImageDimensions.current.width > 0) {
952
+ updateImageDisplayRect(wrapper.width, wrapper.height);
953
+ imageRect = imageDisplayRect.current;
954
+ }
955
+ }
956
+
957
+ if (imageRect.width > 0 && imageRect.height > 0) {
958
+ const imageRectX = wrapper.x + imageRect.x;
959
+ const imageRectY = wrapper.y + imageRect.y;
960
+ const imageRectMaxX = imageRectX + imageRect.width;
961
+ const imageRectMaxY = imageRectY + imageRect.height;
962
+
963
+ // Clamp the dragged point back to strict image bounds
964
+ setPoints(prev => {
965
+ // ✅ SAFETY: Ensure prev is a valid array
966
+ if (!Array.isArray(prev) || prev.length === 0) {
967
+ return prev;
968
+ }
969
+
970
+ const pointIndex = selectedPointIndex.current;
971
+ // ✅ SAFETY: Validate pointIndex and ensure point exists
972
+ if (pointIndex === null || pointIndex === undefined || pointIndex < 0 || pointIndex >= prev.length) {
973
+ return prev;
974
+ }
975
+
976
+ const point = prev[pointIndex];
977
+ // ✅ SAFETY: Ensure point exists and has valid x/y properties
978
+ if (!point || typeof point.x !== 'number' || typeof point.y !== 'number') {
979
+ return prev;
980
+ }
981
+
982
+ const clampedPoint = {
983
+ x: Math.max(imageRectX, Math.min(point.x, imageRectMaxX)),
984
+ y: Math.max(imageRectY, Math.min(point.y, imageRectMaxY))
985
+ };
986
+
987
+ // Only update if point was outside bounds
988
+ if (point.x !== clampedPoint.x || point.y !== clampedPoint.y) {
989
+ console.log("🔒 Clamping point back to image bounds on release:", {
990
+ before: { x: point.x.toFixed(2), y: point.y.toFixed(2) },
991
+ after: { x: clampedPoint.x.toFixed(2), y: clampedPoint.y.toFixed(2) },
992
+ bounds: { minX: imageRectX.toFixed(2), maxX: imageRectMaxX.toFixed(2), minY: imageRectY.toFixed(2), maxY: imageRectMaxY.toFixed(2) }
993
+ });
994
+
995
+ return prev.map((p, i) => i === pointIndex ? clampedPoint : p);
996
+ }
997
+
998
+ return prev;
999
+ });
1000
+ }
1001
+ }
1002
+
968
1003
  // ✅ FREE DRAG: Clear initial positions when drag ends
969
1004
  initialTouchPosition.current = null;
970
1005
  initialPointPosition.current = null;
971
- const wasDragging = selectedPointIndex.current !== null;
1006
+ lastValidPosition.current = null;
972
1007
  selectedPointIndex.current = null;
973
1008
 
974
1009
  // ✅ CRITICAL: Re-enable parent ScrollView scrolling when drag ends
@@ -986,6 +1021,7 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage ,addheight}) =>
986
1021
 
987
1022
  const handleReset = () => {
988
1023
  // setPoints([]);
1024
+ hasInitializedCropBox.current = false; // ✅ CRITICAL: Reset guard to allow reinitialization
989
1025
  initializeCropBox();
990
1026
  };
991
1027
 
@@ -1050,6 +1086,9 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage ,addheight}) =>
1050
1086
  greenFrame: frameData.greenFrame,
1051
1087
  capturedImageSize: frameData.capturedImageSize
1052
1088
  };
1089
+ // ✅ CRITICAL: Set imageSource to 'camera' IMMEDIATELY to prevent Image.getSize() from being called
1090
+ imageSource.current = 'camera';
1091
+ hasInitializedCropBox.current = false; // Reset guard for new camera image
1053
1092
  console.log("✅ Camera frame data received:", cameraFrameData.current);
1054
1093
  }
1055
1094
  setImage(uri);
@@ -1064,58 +1103,152 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage ,addheight}) =>
1064
1103
  />
1065
1104
  ) : (
1066
1105
  <>
1067
- {!showResult && (
1068
- <View style={image ? styles.buttonContainer : styles.centerButtonsContainer}>
1069
-
1070
- {image && Platform.OS === 'android' && (
1071
- <>
1072
- <TouchableOpacity
1073
- style={styles.iconButton}
1074
- onPress={() => enableRotation && rotatePreviewImage(90)}
1075
- disabled={isRotating}
1076
- >
1077
- <Ionicons name="sync" size={24} color="white" />
1078
- </TouchableOpacity>
1079
- </>
1080
- )}
1081
-
1082
- {image && (
1083
- <TouchableOpacity style={styles.button} onPress={handleReset}>
1084
- <Text style={styles.buttonText}>Reset</Text>
1106
+ {image && (
1107
+ <View
1108
+ style={{
1109
+ width: Dimensions.get('window').width,
1110
+ aspectRatio: 9 / 16,
1111
+ borderRadius: 30,
1112
+ overflow: 'hidden',
1113
+ alignItems: 'center',
1114
+ justifyContent: 'center',
1115
+ position: 'relative',
1116
+ backgroundColor: 'black',
1117
+ marginBottom: 0, // ✅ Les boutons sont maintenant en position absolue en bas
1118
+ }}
1119
+ ref={commonWrapperRef}
1120
+ onLayout={onCommonWrapperLayout}
1121
+ >
1122
+ <View
1123
+ ref={viewRef}
1124
+ collapsable={false}
1125
+ style={StyleSheet.absoluteFill}
1126
+ onStartShouldSetResponder={() => true}
1127
+ onMoveShouldSetResponder={(evt, gestureState) => {
1128
+ // ✅ CRITICAL: Always capture movement when a point is selected
1129
+ // This ensures vertical movement is captured correctly
1130
+ if (selectedPointIndex.current !== null) {
1131
+ return true;
1132
+ }
1133
+ // ✅ CRITICAL: Capture ANY movement immediately (even 0px) to prevent ScrollView interception
1134
+ // This is especially important for vertical movement which ScrollView tries to intercept
1135
+ // We return true for ANY movement to ensure we capture it before ScrollView
1136
+ const hasMovement = Math.abs(gestureState.dx) > 0 || Math.abs(gestureState.dy) > 0;
1137
+ if (hasMovement && Math.abs(gestureState.dy) > 5) {
1138
+ console.log("🔄 Vertical movement detected in responder:", {
1139
+ dx: gestureState.dx.toFixed(2),
1140
+ dy: gestureState.dy.toFixed(2),
1141
+ selectedPoint: selectedPointIndex.current
1142
+ });
1143
+ }
1144
+ return true;
1145
+ }}
1146
+ onResponderGrant={(e) => {
1147
+ // ✅ CRITICAL: Grant responder immediately to prevent ScrollView from intercepting
1148
+ // This ensures we capture all movement, especially vertical
1149
+ // Handle tap to select point if needed
1150
+ if (selectedPointIndex.current === null) {
1151
+ handleTap(e);
1152
+ }
1153
+ }}
1154
+ onResponderStart={handleTap}
1155
+ onResponderMove={(e) => {
1156
+ // ✅ CRITICAL: Always handle move events to ensure smooth movement in all directions
1157
+ // This is called for every move event, ensuring vertical movement is captured
1158
+ // handleMove now uses incremental delta calculation which is more reliable
1159
+ handleMove(e);
1160
+ }}
1161
+ onResponderRelease={handleRelease}
1162
+ onResponderTerminationRequest={() => {
1163
+ // ✅ CRITICAL: Never allow termination when dragging a point
1164
+ // This prevents ScrollView from stealing the responder during vertical movement
1165
+ return selectedPointIndex.current === null;
1166
+ }}
1167
+ // ✅ CRITICAL: Prevent parent ScrollView from intercepting touches
1168
+ // Capture responder BEFORE parent ScrollView can intercept
1169
+ onStartShouldSetResponderCapture={() => {
1170
+ // Always capture start events
1171
+ return true;
1172
+ }}
1173
+ onMoveShouldSetResponderCapture={(evt, gestureState) => {
1174
+ // ✅ CRITICAL: Always capture movement events before parent ScrollView
1175
+ // This is essential for vertical movement which ScrollView tries to intercept
1176
+ // Especially important when a point is selected or when there's any movement
1177
+ if (selectedPointIndex.current !== null) {
1178
+ return true;
1179
+ }
1180
+ // ✅ CRITICAL: Capture movement BEFORE ScrollView can intercept
1181
+ // This ensures we get vertical movement even if ScrollView tries to steal it
1182
+ const hasMovement = Math.abs(gestureState.dx) > 0 || Math.abs(gestureState.dy) > 0;
1183
+ return hasMovement;
1184
+ }}
1185
+ >
1186
+ <Image
1187
+ source={{ uri: image }}
1188
+ style={styles.image}
1189
+ resizeMode={cameraFrameData.current?.greenFrame ? 'cover' : 'contain'}
1190
+ onLayout={onImageLayout}
1191
+ />
1192
+ {/* ✅ RÉFÉRENTIEL UNIQUE : SVG overlay utilise les dimensions du wrapper commun */}
1193
+ {/* IMPORTANT: prevent SVG overlay from stealing touch events so dragging works reliably */}
1194
+ <Svg style={styles.overlay} pointerEvents="none">
1195
+ {(() => {
1196
+ // ✅ Use wrapper dimensions for SVG path (wrapper coordinates)
1197
+ const wrapperWidth = commonWrapperLayout.current.width || Dimensions.get('window').width;
1198
+ const wrapperHeight = commonWrapperLayout.current.height || (Dimensions.get('window').width * 16 / 9);
1199
+ return (
1200
+ <>
1201
+ <Path
1202
+ d={`M 0 0 H ${wrapperWidth} V ${wrapperHeight} H 0 Z ${createPath()}`}
1203
+ fill={showResult ? 'white' : 'rgba(0, 0, 0, 0.8)'}
1204
+ fillRule="evenodd"
1205
+ />
1206
+ {!showResult && points.length > 0 && (
1207
+ <Path d={createPath()} fill="transparent" stroke="white" strokeWidth={2} />
1208
+ )}
1209
+ {!showResult && points.map((point, index) => (
1210
+ <Circle key={index} cx={point.x} cy={point.y} r={10} fill="white" />
1211
+ ))}
1212
+ </>
1213
+ );
1214
+ })()}
1215
+ </Svg>
1216
+ </View>
1217
+ </View>
1218
+ )}
1219
+
1220
+ {/* ✅ Buttons positioned BELOW the image, not overlapping */}
1221
+ {!showResult && image && (
1222
+ <View style={[styles.buttonContainerBelow, { paddingBottom: Math.max(insets.bottom, 16) }]}>
1223
+ {Platform.OS === 'android' && (
1224
+ <TouchableOpacity
1225
+ style={styles.rotationButton}
1226
+ onPress={() => enableRotation && rotatePreviewImage(90)}
1227
+ disabled={isRotating}
1228
+ >
1229
+ <Ionicons name="sync" size={24} color="white" />
1085
1230
  </TouchableOpacity>
1086
1231
  )}
1087
- {image && (
1232
+
1233
+ <TouchableOpacity style={styles.button} onPress={handleReset}>
1234
+ <Text style={styles.buttonText}>Reset</Text>
1235
+ </TouchableOpacity>
1236
+
1088
1237
  <TouchableOpacity
1089
1238
  style={styles.button}
1090
- onPress={async () => {
1239
+ onPress={async () => {
1091
1240
  setIsLoading(true);
1092
1241
  try {
1093
1242
  console.log("=== Starting pixel-perfect metadata export (no bitmap crop on mobile) ===");
1094
1243
 
1095
- // ✅ REFACTORISATION : Utiliser les dimensions stockées (plus efficace)
1096
1244
  const actualImageWidth = originalImageDimensions.current.width;
1097
1245
  const actualImageHeight = originalImageDimensions.current.height;
1098
1246
 
1099
- // Vérifier que les dimensions sont valides
1100
1247
  if (actualImageWidth === 0 || actualImageHeight === 0) {
1101
1248
  throw new Error("Original image dimensions not available. Please wait for image to load.");
1102
1249
  }
1103
1250
 
1104
- console.log("Original image dimensions:", {
1105
- width: actualImageWidth,
1106
- height: actualImageHeight
1107
- });
1108
-
1109
- // ✅ CORRECTION : Utiliser le rectangle de contenu réel (contain)
1110
- // ✅ CRITICAL: Recalculate displayedContentRect if not available
1111
1251
  const layout = displayedImageLayout.current;
1112
-
1113
- console.log("🔍 Checking displayedContentRect before crop:", {
1114
- displayedContentRect: displayedContentRect.current,
1115
- displayedImageLayout: layout,
1116
- originalImageDimensions: originalImageDimensions.current
1117
- });
1118
-
1119
1252
  if (layout.width > 0 && layout.height > 0) {
1120
1253
  updateDisplayedContentRect(layout.width, layout.height);
1121
1254
  }
@@ -1124,12 +1257,8 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage ,addheight}) =>
1124
1257
  let displayedWidth = contentRect.width;
1125
1258
  let displayedHeight = contentRect.height;
1126
1259
 
1127
- // Vérifier que les dimensions d'affichage sont valides
1128
1260
  if (displayedWidth === 0 || displayedHeight === 0) {
1129
- // ✅ FALLBACK: Try to use displayedImageLayout if contentRect is not available
1130
1261
  if (layout.width > 0 && layout.height > 0) {
1131
- console.warn("⚠️ displayedContentRect not available, using displayedImageLayout as fallback");
1132
- // Use layout dimensions as fallback (assuming no letterboxing)
1133
1262
  contentRect = {
1134
1263
  x: layout.x,
1135
1264
  y: layout.y,
@@ -1138,85 +1267,52 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage ,addheight}) =>
1138
1267
  };
1139
1268
  displayedWidth = contentRect.width;
1140
1269
  displayedHeight = contentRect.height;
1141
- // Update the ref for consistency
1142
1270
  displayedContentRect.current = contentRect;
1143
1271
  } else {
1144
- throw new Error("Displayed image dimensions not available. Image may not be laid out yet. Please wait a moment and try again.");
1272
+ throw new Error("Displayed image dimensions not available.");
1145
1273
  }
1146
1274
  }
1147
1275
 
1148
- console.log("✅ Using contentRect for crop:", contentRect);
1149
-
1150
- console.log("Displayed image dimensions:", {
1151
- width: displayedWidth,
1152
- height: displayedHeight
1153
- });
1154
-
1155
- // ✅ CORRECTION : avec resizeMode='contain', l'échelle est UNIFORME + offsets.
1156
- // Vérifier que le scale est cohérent (même ratio pour width et height)
1157
- const scaleX = actualImageWidth / displayedWidth;
1158
- const scaleY = actualImageHeight / displayedHeight;
1159
-
1160
- // Pour resizeMode='contain', scaleX et scaleY doivent être égaux
1161
- // Si ce n'est pas le cas, il y a un problème de calcul
1162
- if (Math.abs(scaleX - scaleY) > 0.01) {
1163
- console.warn("Scale mismatch detected! This may cause incorrect crop coordinates.", {
1164
- scaleX,
1165
- scaleY,
1166
- actualImageWidth,
1167
- actualImageHeight,
1168
- displayedWidth,
1169
- displayedHeight
1170
- });
1276
+ const isCoverMode = !!(cameraFrameData.current && cameraFrameData.current.greenFrame);
1277
+ let scale, coverOffsetX = 0, coverOffsetY = 0;
1278
+ if (isCoverMode) {
1279
+ scale = Math.max(displayedWidth / actualImageWidth, displayedHeight / actualImageHeight);
1280
+ const scaledWidth = actualImageWidth * scale;
1281
+ const scaledHeight = actualImageHeight * scale;
1282
+ coverOffsetX = (scaledWidth - displayedWidth) / 2;
1283
+ coverOffsetY = (scaledHeight - displayedHeight) / 2;
1284
+ } else {
1285
+ scale = actualImageWidth / displayedWidth;
1171
1286
  }
1172
1287
 
1173
- const scale = scaleX; // Utiliser scaleX (ou scaleY, ils devraient être égaux)
1174
- console.log("Scale factor (contain, uniform):", {
1175
- scale,
1176
- scaleX,
1177
- scaleY,
1178
- contentRect,
1179
- actualImageSize: { width: actualImageWidth, height: actualImageHeight },
1180
- displayedSize: { width: displayedWidth, height: displayedHeight }
1181
- });
1182
-
1183
1288
  const originalUri = sourceImageUri.current || image;
1184
1289
  let cropMeta = null;
1185
1290
 
1186
1291
  if (points.length > 0) {
1187
1292
  try {
1188
- console.log("Calculating crop boundaries from points...");
1189
- console.log("Points (display coordinates):", points);
1190
- console.log("Content rect (offsets):", contentRect);
1191
-
1192
- // ✅ CORRECTION : conversion display -> image px (contain) avec offsets
1193
- // S'assurer que les points sont bien dans les limites de l'image affichée
1194
1293
  const imagePoints = points.map(point => {
1195
- // Clamp les coordonnées d'affichage à la zone réelle de l'image
1196
- const clampedX = Math.max(contentRect.x, Math.min(point.x, contentRect.x + contentRect.width));
1197
- const clampedY = Math.max(contentRect.y, Math.min(point.y, contentRect.y + contentRect.height));
1198
-
1199
- // Convertir en coordonnées de l'image originale
1200
- const origX = (clampedX - contentRect.x) * scale;
1201
- const origY = (clampedY - contentRect.y) * scale;
1202
-
1203
- // Clamp aux dimensions de l'image originale
1294
+ let clampedX, clampedY, origX, origY;
1295
+ if (isCoverMode) {
1296
+ clampedX = Math.max(0, Math.min(point.x, contentRect.width));
1297
+ clampedY = Math.max(0, Math.min(point.y, contentRect.height));
1298
+ origX = (clampedX + coverOffsetX) / scale;
1299
+ origY = (clampedY + coverOffsetY) / scale;
1300
+ } else {
1301
+ clampedX = Math.max(contentRect.x, Math.min(point.x, contentRect.x + contentRect.width));
1302
+ clampedY = Math.max(contentRect.y, Math.min(point.y, contentRect.y + contentRect.height));
1303
+ origX = (clampedX - contentRect.x) * scale;
1304
+ origY = (clampedY - contentRect.y) * scale;
1305
+ }
1204
1306
  const finalX = Math.max(0, Math.min(origX, actualImageWidth));
1205
1307
  const finalY = Math.max(0, Math.min(origY, actualImageHeight));
1206
-
1207
1308
  return { x: finalX, y: finalY };
1208
1309
  });
1209
1310
 
1210
- console.log("Converted image points (original coordinates):", imagePoints);
1211
-
1212
- // Calculer la bounding box : min X, min Y, max X, max Y
1213
1311
  const minX = Math.min(...imagePoints.map(p => p.x));
1214
1312
  const minY = Math.min(...imagePoints.map(p => p.y));
1215
1313
  const maxX = Math.max(...imagePoints.map(p => p.x));
1216
1314
  const maxY = Math.max(...imagePoints.map(p => p.y));
1217
1315
 
1218
- // ✅ CORRECTION : arrondi "conservateur" (floor origin + ceil end)
1219
- // évite de rogner des pixels et réduit le risque de crop plus petit (perte de détails).
1220
1316
  const cropX = Math.max(0, Math.floor(minX));
1221
1317
  const cropY = Math.max(0, Math.floor(minY));
1222
1318
  const cropEndX = Math.min(actualImageWidth, Math.ceil(maxX));
@@ -1224,25 +1320,8 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage ,addheight}) =>
1224
1320
  const cropWidth = Math.max(0, cropEndX - cropX);
1225
1321
  const cropHeight = Math.max(0, cropEndY - cropY);
1226
1322
 
1227
- console.log("Crop parameters (pixel-perfect):", {
1228
- x: cropX,
1229
- y: cropY,
1230
- width: cropWidth,
1231
- height: cropHeight,
1232
- imageWidth: actualImageWidth,
1233
- imageHeight: actualImageHeight,
1234
- boundingBox: {
1235
- minX,
1236
- minY,
1237
- maxX,
1238
- maxY
1239
- }
1240
- });
1241
-
1242
1323
  if (cropWidth > 0 && cropHeight > 0) {
1243
- // 1) bbox in ORIGINAL image pixel coords
1244
1324
  const bbox = { x: cropX, y: cropY, width: cropWidth, height: cropHeight };
1245
- // 2) polygon points relative to bbox (still in ORIGINAL pixel grid)
1246
1325
  const polygon = imagePoints.map(point => ({
1247
1326
  x: point.x - cropX,
1248
1327
  y: point.y - cropY
@@ -1253,21 +1332,14 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage ,addheight}) =>
1253
1332
  rotation: 0,
1254
1333
  imageSize: { width: actualImageWidth, height: actualImageHeight },
1255
1334
  };
1256
- console.log("Crop meta ready:", cropMeta);
1257
- } else {
1258
- console.warn("Invalid crop dimensions, cannot export crop meta");
1259
1335
  }
1260
1336
  } catch (cropError) {
1261
1337
  console.error("Error computing crop meta:", cropError);
1262
1338
  }
1263
- } else {
1264
- console.log("No crop points defined, using original image");
1265
1339
  }
1266
1340
 
1267
1341
  const name = `IMAGE XTK${Date.now()}`;
1268
-
1269
1342
  if (onConfirm) {
1270
- console.log("Calling onConfirm with:", originalUri, name, cropMeta);
1271
1343
  onConfirm(originalUri, name, cropMeta);
1272
1344
  }
1273
1345
  } catch (error) {
@@ -1279,101 +1351,16 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage ,addheight}) =>
1279
1351
  setShowFullScreenCapture(false);
1280
1352
  }
1281
1353
  }}
1282
- >
1283
- <Text style={styles.buttonText}>Confirm</Text>
1284
- </TouchableOpacity>
1285
- )}
1286
-
1354
+ >
1355
+ <Text style={styles.buttonText}>Confirm</Text>
1356
+ </TouchableOpacity>
1287
1357
  </View>
1288
1358
  )}
1289
1359
 
1290
-
1291
- {image && (
1292
- <View
1293
- ref={viewRef}
1294
- collapsable={false}
1295
- style={showFullScreenCapture ? styles.fullscreenImageContainer : styles.imageContainer}
1296
- onStartShouldSetResponder={() => true}
1297
- onMoveShouldSetResponder={(evt, gestureState) => {
1298
- // ✅ CRITICAL: Always capture movement when a point is selected
1299
- // This ensures vertical movement is captured correctly
1300
- if (selectedPointIndex.current !== null) {
1301
- return true;
1302
- }
1303
- // ✅ CRITICAL: Capture ANY movement immediately (even 0px) to prevent ScrollView interception
1304
- // This is especially important for vertical movement which ScrollView tries to intercept
1305
- // We return true for ANY movement to ensure we capture it before ScrollView
1306
- const hasMovement = Math.abs(gestureState.dx) > 0 || Math.abs(gestureState.dy) > 0;
1307
- if (hasMovement && Math.abs(gestureState.dy) > 5) {
1308
- console.log("🔄 Vertical movement detected in responder:", {
1309
- dx: gestureState.dx.toFixed(2),
1310
- dy: gestureState.dy.toFixed(2),
1311
- selectedPoint: selectedPointIndex.current
1312
- });
1313
- }
1314
- return true;
1315
- }}
1316
- onResponderGrant={(e) => {
1317
- // ✅ CRITICAL: Grant responder immediately to prevent ScrollView from intercepting
1318
- // This ensures we capture all movement, especially vertical
1319
- // Handle tap to select point if needed
1320
- if (selectedPointIndex.current === null) {
1321
- handleTap(e);
1322
- }
1323
- }}
1324
- onResponderStart={handleTap}
1325
- onResponderMove={(e) => {
1326
- // ✅ CRITICAL: Always handle move events to ensure smooth movement in all directions
1327
- // This is called for every move event, ensuring vertical movement is captured
1328
- // handleMove now uses incremental delta calculation which is more reliable
1329
- handleMove(e);
1330
- }}
1331
- onResponderRelease={handleRelease}
1332
- onResponderTerminationRequest={() => {
1333
- // ✅ CRITICAL: Never allow termination when dragging a point
1334
- // This prevents ScrollView from stealing the responder during vertical movement
1335
- return selectedPointIndex.current === null;
1336
- }}
1337
- // ✅ CRITICAL: Prevent parent ScrollView from intercepting touches
1338
- // Capture responder BEFORE parent ScrollView can intercept
1339
- onStartShouldSetResponderCapture={() => {
1340
- // Always capture start events
1341
- return true;
1342
- }}
1343
- onMoveShouldSetResponderCapture={(evt, gestureState) => {
1344
- // ✅ CRITICAL: Always capture movement events before parent ScrollView
1345
- // This is essential for vertical movement which ScrollView tries to intercept
1346
- // Especially important when a point is selected or when there's any movement
1347
- if (selectedPointIndex.current !== null) {
1348
- return true;
1349
- }
1350
- // Also capture if there's any movement to prevent ScrollView from intercepting
1351
- const hasMovement = Math.abs(gestureState.dx) > 0 || Math.abs(gestureState.dy) > 0;
1352
- if (hasMovement && Math.abs(gestureState.dy) > 2) {
1353
- console.log("🔄 Capturing vertical movement before ScrollView:", { dy: gestureState.dy.toFixed(2) });
1354
- }
1355
- return hasMovement;
1356
- }}
1357
- // ✅ CRITICAL: Prevent ScrollView from scrolling by stopping propagation
1358
- onResponderReject={() => {
1359
- console.warn("⚠️ Responder rejected - ScrollView may intercept");
1360
- }}
1361
- >
1362
- <Image source={{ uri: image }} style={styles.image} onLayout={onImageLayout} />
1363
- {/* IMPORTANT: prevent SVG overlay from stealing touch events so dragging works reliably */}
1364
- <Svg style={styles.overlay} pointerEvents="none">
1365
- <Path
1366
- d={`M 0 0 H ${imageMeasure.current.width} V ${imageMeasure.current.height} H 0 Z ${createPath()}`}
1367
- fill={showResult ? 'white' : 'rgba(0, 0, 0, 0.8)'}
1368
- fillRule="evenodd"
1369
- />
1370
- {!showResult && points.length > 0 && (
1371
- <Path d={createPath()} fill="transparent" stroke="white" strokeWidth={2} />
1372
- )}
1373
- {!showResult && points.map((point, index) => (
1374
- <Circle key={index} cx={point.x} cy={point.y} r={10} fill="white" />
1375
- ))}
1376
- </Svg>
1360
+ {/* ✅ Show welcome screen when no image */}
1361
+ {!showResult && !image && (
1362
+ <View style={styles.centerButtonsContainer}>
1363
+ <Text style={styles.welcomeText}>Sélectionnez une image</Text>
1377
1364
  </View>
1378
1365
  )}
1379
1366
  </>
@@ -1386,13 +1373,13 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage ,addheight}) =>
1386
1373
  <View
1387
1374
  style={{
1388
1375
  position: 'absolute',
1389
- left: -maskDimensions.width - 100, // Hors écran mais pas trop loin
1376
+ left: -maskDimensions.width - 100,
1390
1377
  top: -maskDimensions.height - 100,
1391
1378
  width: maskDimensions.width,
1392
1379
  height: maskDimensions.height,
1393
- opacity: 1, // Opacité normale pour la capture
1380
+ opacity: 1,
1394
1381
  pointerEvents: 'none',
1395
- zIndex: 9999, // Z-index élevé pour s'assurer qu'elle est au-dessus
1382
+ zIndex: 9999,
1396
1383
  overflow: 'hidden',
1397
1384
  }}
1398
1385
  collapsable={false}