react-native-expo-cropper 1.2.36 → 1.2.37

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.
@@ -20,6 +20,7 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage ,addheight}) =>
20
20
  const viewRef = useRef(null);
21
21
  const maskViewRef = useRef(null); // Ref pour la vue de masque (invisible)
22
22
  const sourceImageUri = useRef(null); // keep original image URI (full-res) for upload
23
+ const cameraFrameData = useRef(null); // ✅ Store green frame coordinates from camera
23
24
 
24
25
  // ✅ REFACTORISATION : Séparation claire entre dimensions originales et affichage
25
26
  // Dimensions réelles de l'image originale (pixels)
@@ -35,6 +36,12 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage ,addheight}) =>
35
36
  const updateDisplayedContentRect = (layoutWidth, layoutHeight) => {
36
37
  const iw = originalImageDimensions.current.width;
37
38
  const ih = originalImageDimensions.current.height;
39
+
40
+ console.log("🔄 updateDisplayedContentRect called:", {
41
+ originalDimensions: { width: iw, height: ih },
42
+ layoutDimensions: { width: layoutWidth, height: layoutHeight }
43
+ });
44
+
38
45
  if (iw > 0 && ih > 0 && layoutWidth > 0 && layoutHeight > 0) {
39
46
  const scale = Math.min(layoutWidth / iw, layoutHeight / ih);
40
47
  const contentW = iw * scale;
@@ -47,15 +54,28 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage ,addheight}) =>
47
54
  width: contentW,
48
55
  height: contentH,
49
56
  };
50
- console.log("Displayed content rect (contain):", displayedContentRect.current);
57
+ console.log("Displayed content rect (contain) calculated:", displayedContentRect.current);
51
58
  return;
52
59
  }
53
- displayedContentRect.current = { x: 0, y: 0, width: layoutWidth || 0, height: layoutHeight || 0 };
60
+
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);
65
+ } else {
66
+ displayedContentRect.current = { x: 0, y: 0, width: 0, height: 0 };
67
+ console.warn("❌ Cannot calculate displayedContentRect: missing dimensions");
68
+ }
54
69
  };
55
70
 
56
71
  const selectedPointIndex = useRef(null);
57
72
  const lastTap = useRef(null);
58
73
 
74
+ // ✅ FREE DRAG: Store initial touch position and point position for delta-based movement
75
+ const initialTouchPosition = useRef(null); // { x, y } - initial touch position when drag starts
76
+ const initialPointPosition = useRef(null); // { x, y } - initial point position when drag starts
77
+ const lastTouchPosition = useRef(null); // { x, y } - last touch position for incremental delta calculation
78
+
59
79
  // Angle de rotation accumulé (pour éviter les rotations multiples)
60
80
  const rotationAngle = useRef(0);
61
81
 
@@ -87,6 +107,12 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage ,addheight}) =>
87
107
  } else if (initialImage) {
88
108
  setImage(initialImage);
89
109
  sourceImageUri.current = initialImage;
110
+ // ✅ CRITICAL: Reset points when loading a new image from gallery
111
+ // This ensures the crop box will be automatically initialized
112
+ setPoints([]);
113
+ rotationAngle.current = 0;
114
+ // Clear camera frame data for gallery images
115
+ cameraFrameData.current = null;
90
116
  }
91
117
  }, [openCameraFirst, initialImage]);
92
118
 
@@ -104,8 +130,33 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage ,addheight}) =>
104
130
  sourceImageUri.current = image;
105
131
  }
106
132
 
107
- // ✅ FIX: Use Image.getSize() which works correctly on Android
108
- // On iOS, it returns logical points, but we'll handle that by sending dimensions to backend
133
+ // ✅ CRITICAL FIX: If we have capturedImageSize from camera, use it as source of truth
134
+ // takePictureAsync returns physical dimensions, while Image.getSize() may return EXIF-oriented dimensions
135
+ if (cameraFrameData.current && cameraFrameData.current.capturedImageSize) {
136
+ const { width: capturedWidth, height: capturedHeight } = cameraFrameData.current.capturedImageSize;
137
+ originalImageDimensions.current = {
138
+ width: capturedWidth,
139
+ height: capturedHeight,
140
+ };
141
+ console.log("✅ Using captured image dimensions from takePictureAsync:", {
142
+ width: capturedWidth,
143
+ height: capturedHeight,
144
+ source: 'takePictureAsync'
145
+ });
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) {
153
+ initializeCropBox();
154
+ }
155
+ }
156
+ return;
157
+ }
158
+
159
+ // ✅ FALLBACK: Use Image.getSize() if no captured dimensions available (e.g., from gallery)
109
160
  Image.getSize(image, (imgWidth, imgHeight) => {
110
161
  originalImageDimensions.current = {
111
162
  width: imgWidth,
@@ -117,7 +168,8 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage ,addheight}) =>
117
168
  height: imgHeight,
118
169
  platform: Platform.OS,
119
170
  pixelRatio: PixelRatio.get(),
120
- uri: image
171
+ uri: image,
172
+ source: 'Image.getSize()'
121
173
  });
122
174
 
123
175
  // ✅ IMPORTANT: onImageLayout peut se déclencher avant Image.getSize (race condition).
@@ -126,6 +178,12 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage ,addheight}) =>
126
178
  const lh = displayedImageLayout.current.height;
127
179
  if (lw > 0 && lh > 0) {
128
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) {
185
+ initializeCropBox();
186
+ }
129
187
  }
130
188
  }, (error) => {
131
189
  console.error("Error getting image size:", error);
@@ -133,30 +191,475 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage ,addheight}) =>
133
191
  }, [image]);
134
192
 
135
193
 
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
+
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;
270
+ }
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
+
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;
312
+ }
313
+
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)
381
+ }
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
+ }
426
+
427
+ // Convert image pixel coordinates to display coordinates
428
+ const scaleX = displayContentWidth / imgWidth;
429
+ const scaleY = displayContentHeight / imgHeight;
430
+
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);
441
+
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
471
+ }
472
+ };
473
+
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
+ };
495
+
136
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
137
498
  const initializeCropBox = () => {
138
- const { x, y, width, height } = displayedContentRect.current;
139
- if (width === 0 || height === 0) {
140
- console.warn("Cannot initialize crop box: displayed dimensions are zero");
141
- return;
499
+ // CRITICAL: Ensure displayedContentRect is available
500
+ let contentRect = displayedContentRect.current;
501
+ const layout = displayedImageLayout.current;
502
+
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
+ }
142
523
  }
143
524
 
144
- const boxWidth = width * 0.8;
145
- const boxHeight = height * 0.8;
146
- const centerX = x + width / 2;
147
- const centerY = y + height / 2;
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
+ }
642
+ }
643
+
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
148
649
  const newPoints = [
149
- { x: centerX - boxWidth / 2, y: centerY - boxHeight / 2 },
150
- { x: centerX + boxWidth / 2, y: centerY - boxHeight / 2 },
151
- { x: centerX + boxWidth / 2, y: centerY + boxHeight / 2 },
152
- { x: centerX - boxWidth / 2, y: centerY + boxHeight / 2 },
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
153
654
  ];
154
655
 
155
- console.log("Initializing crop box:", {
656
+ console.log("Initializing crop box (default - 70% centered):", {
156
657
  displayedWidth: width,
157
658
  displayedHeight: height,
158
659
  boxWidth,
159
660
  boxHeight,
661
+ boxX,
662
+ boxY,
160
663
  points: newPoints
161
664
  });
162
665
 
@@ -194,12 +697,27 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage ,addheight}) =>
194
697
  y: layout.y
195
698
  });
196
699
 
197
- // ✅ CORRECTION : Initialiser le crop box dès que les dimensions d'affichage sont disponibles
198
- // Les dimensions d'affichage sont suffisantes pour afficher la bounding box
199
- // Les dimensions originales seront utilisées plus tard pour la conversion de coordonnées lors du crop
200
- if (layout.width > 0 && layout.height > 0) {
201
- // Initialiser seulement si les points sont vides (première fois) ou si on vient de charger une nouvelle image
202
- if (points.length === 0) {
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) {
709
+ 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
203
721
  initializeCropBox();
204
722
  }
205
723
  }
@@ -217,8 +735,34 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage ,addheight}) =>
217
735
  const now = Date.now();
218
736
  const { locationX: tapX, locationY: tapY } = e.nativeEvent;
219
737
 
738
+ // ✅ CRITICAL: Ensure displayedContentRect is available
739
+ let contentRect = displayedContentRect.current;
740
+ const layout = displayedImageLayout.current;
741
+
742
+ // 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;
747
+ }
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
756
+ };
757
+ } else {
758
+ console.warn("⚠️ Cannot handle tap: no layout dimensions available");
759
+ return;
760
+ }
761
+ }
762
+ }
763
+
220
764
  // ✅ Clamp to real displayed image content (avoid points outside image due to letterboxing)
221
- const { x: cx, y: cy, width: cw, height: ch } = displayedContentRect.current;
765
+ const { x: cx, y: cy, width: cw, height: ch } = contentRect;
222
766
  const boundedTapX = Math.max(cx, Math.min(tapX, cx + cw));
223
767
  const boundedTapY = Math.max(cy, Math.min(tapY, cy + ch));
224
768
 
@@ -231,7 +775,28 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage ,addheight}) =>
231
775
  const index = points.findIndex(p => Math.abs(p.x - boundedTapX) < selectRadius && Math.abs(p.y - boundedTapY) < selectRadius);
232
776
  if (index !== -1) {
233
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] };
234
782
  lastValidPosition.current = { ...points[index] }; // store original position before move
783
+
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
799
+ }
235
800
  }
236
801
  lastTap.current = now;
237
802
  }
@@ -239,36 +804,158 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage ,addheight}) =>
239
804
 
240
805
  const handleMove = (e) => {
241
806
  if (showResult || selectedPointIndex.current === null) return;
242
-
243
- const { locationX: moveX, locationY: moveY } = e.nativeEvent;
244
- // ✅ CORRECTION : limiter les points à la zone RÉELLE de l'image (contain), pas au conteneur complet
245
- const { x: cx, y: cy, width: cw, height: ch } = displayedContentRect.current;
246
- const boundedX = Math.max(cx, Math.min(moveX, cx + cw));
247
- const boundedY = Math.max(cy, Math.min(moveY, cy + ch));
248
-
249
- const edgeThreshold = 10;
250
- const isNearTopOrBottomEdge =
251
- boundedY <= cy + edgeThreshold || boundedY >= (cy + ch) - edgeThreshold;
252
-
253
- const isNearLeftOrRightEdge =
254
- boundedX <= cx + edgeThreshold || boundedX >= (cx + cw) - edgeThreshold;
255
-
256
- if (isNearTopOrBottomEdge || isNearLeftOrRightEdge) {
257
- // Reset point to last known position
258
- if (lastValidPosition.current && selectedPointIndex.current !== null) {
259
- setPoints(prev =>
260
- prev.map((p, i) =>
261
- i === selectedPointIndex.current ? lastValidPosition.current : p
262
- )
263
- );
264
- }
265
- selectedPointIndex.current = null;
807
+
808
+ // 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
812
+ const nativeEvent = e.nativeEvent;
813
+ const currentX = nativeEvent.locationX;
814
+ const currentY = nativeEvent.locationY;
815
+
816
+ // Validate coordinates
817
+ if (currentX === undefined || currentY === undefined || isNaN(currentX) || isNaN(currentY)) {
818
+ console.warn("⚠️ Cannot get touch coordinates", {
819
+ locationX: nativeEvent.locationX,
820
+ locationY: nativeEvent.locationY
821
+ });
266
822
  return;
267
823
  }
824
+
825
+ // ✅ CRITICAL: Use incremental delta (from last position) instead of absolute delta
826
+ // This is more reliable when ScrollView affects coordinate updates
827
+ let deltaX, deltaY;
828
+ if (lastTouchPosition.current) {
829
+ // Calculate incremental delta from last touch position
830
+ deltaX = currentX - lastTouchPosition.current.x;
831
+ deltaY = currentY - lastTouchPosition.current.y;
832
+ } else if (initialTouchPosition.current) {
833
+ // Fallback to absolute delta if lastTouchPosition not set
834
+ deltaX = currentX - initialTouchPosition.current.x;
835
+ deltaY = currentY - initialTouchPosition.current.y;
836
+ } else {
837
+ console.warn("⚠️ No touch position reference available");
838
+ return;
839
+ }
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;
848
+
849
+ // 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;
854
+ }
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
863
+ };
864
+ } else {
865
+ console.warn("⚠️ Cannot move point: no layout dimensions available");
866
+ return;
867
+ }
868
+ }
869
+ }
870
+
871
+ // ✅ FREE DRAG: Ensure initial positions are set
872
+ if (!initialPointPosition.current) {
873
+ const currentPoint = points[selectedPointIndex.current];
874
+ if (currentPoint) {
875
+ initialPointPosition.current = { ...currentPoint };
876
+ } else {
877
+ console.warn("⚠️ No point found for selected index");
878
+ return;
879
+ }
880
+ }
881
+
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
+ });
901
+ }
902
+
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;
911
+
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
915
+ 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
+
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
933
+ });
934
+ }
935
+
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) }
942
+ });
943
+ }
268
944
 
269
- // Valid move update point and store as new last valid position
945
+ // FREE DRAG: Update point directly
946
+ // - No polygon simplification
947
+ // - No reordering of points
948
+ // - No geometry normalization during drag
270
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
271
953
  lastValidPosition.current = updatedPoint;
954
+
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
958
+ lastTouchPosition.current = { x: currentX, y: currentY };
272
959
 
273
960
  setPoints(prev =>
274
961
  prev.map((p, i) =>
@@ -278,7 +965,23 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage ,addheight}) =>
278
965
  };
279
966
 
280
967
  const handleRelease = () => {
968
+ // ✅ FREE DRAG: Clear initial positions when drag ends
969
+ initialTouchPosition.current = null;
970
+ initialPointPosition.current = null;
971
+ const wasDragging = selectedPointIndex.current !== null;
281
972
  selectedPointIndex.current = null;
973
+
974
+ // ✅ CRITICAL: Re-enable parent ScrollView scrolling when drag ends
975
+ if (wasDragging) {
976
+ try {
977
+ // Re-enable scrolling after a short delay to avoid conflicts
978
+ setTimeout(() => {
979
+ // ScrollView will be re-enabled automatically when responder is released
980
+ }, 100);
981
+ } catch (e) {
982
+ // Ignore errors
983
+ }
984
+ }
282
985
  };
283
986
 
284
987
  const handleReset = () => {
@@ -340,14 +1043,22 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage ,addheight}) =>
340
1043
 
341
1044
  {showCustomCamera ? (
342
1045
  <CustomCamera
343
- onPhotoCaptured={(uri) => {
1046
+ onPhotoCaptured={(uri, frameData) => {
1047
+ // ✅ CRITICAL FIX: Store green frame coordinates for coordinate conversion
1048
+ if (frameData && frameData.greenFrame) {
1049
+ cameraFrameData.current = {
1050
+ greenFrame: frameData.greenFrame,
1051
+ capturedImageSize: frameData.capturedImageSize
1052
+ };
1053
+ console.log("✅ Camera frame data received:", cameraFrameData.current);
1054
+ }
344
1055
  setImage(uri);
345
1056
  setShowCustomCamera(false);
346
1057
  // ✅ CORRECTION : Réinitialiser les points et l'angle de rotation quand une nouvelle photo est capturée
347
1058
  setPoints([]);
348
1059
  rotationAngle.current = 0;
349
- setPoints([]);
350
- rotationAngle.current = 0;
1060
+ // ✅ CRITICAL: initializeCropBox will be called automatically when image layout is ready
1061
+ // The green frame coordinates are stored in cameraFrameData.current and will be used
351
1062
  }}
352
1063
  onCancel={() => setShowCustomCamera(false)}
353
1064
  />
@@ -396,15 +1107,46 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage ,addheight}) =>
396
1107
  });
397
1108
 
398
1109
  // ✅ CORRECTION : Utiliser le rectangle de contenu réel (contain)
399
- const contentRect = displayedContentRect.current;
400
- const displayedWidth = contentRect.width;
401
- const displayedHeight = contentRect.height;
1110
+ // CRITICAL: Recalculate displayedContentRect if not available
1111
+ 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
+ if (layout.width > 0 && layout.height > 0) {
1120
+ updateDisplayedContentRect(layout.width, layout.height);
1121
+ }
1122
+
1123
+ let contentRect = displayedContentRect.current;
1124
+ let displayedWidth = contentRect.width;
1125
+ let displayedHeight = contentRect.height;
402
1126
 
403
1127
  // Vérifier que les dimensions d'affichage sont valides
404
1128
  if (displayedWidth === 0 || displayedHeight === 0) {
405
- throw new Error("Displayed image dimensions not available. Image may not be laid out yet.");
1129
+ // FALLBACK: Try to use displayedImageLayout if contentRect is not available
1130
+ 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
+ contentRect = {
1134
+ x: layout.x,
1135
+ y: layout.y,
1136
+ width: layout.width,
1137
+ height: layout.height
1138
+ };
1139
+ displayedWidth = contentRect.width;
1140
+ displayedHeight = contentRect.height;
1141
+ // Update the ref for consistency
1142
+ displayedContentRect.current = contentRect;
1143
+ } else {
1144
+ throw new Error("Displayed image dimensions not available. Image may not be laid out yet. Please wait a moment and try again.");
1145
+ }
406
1146
  }
407
1147
 
1148
+ console.log("✅ Using contentRect for crop:", contentRect);
1149
+
408
1150
  console.log("Displayed image dimensions:", {
409
1151
  width: displayedWidth,
410
1152
  height: displayedHeight
@@ -552,9 +1294,70 @@ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage ,addheight}) =>
552
1294
  collapsable={false}
553
1295
  style={showFullScreenCapture ? styles.fullscreenImageContainer : styles.imageContainer}
554
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
+ }}
555
1324
  onResponderStart={handleTap}
556
- onResponderMove={handleMove}
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
+ }}
557
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
+ }}
558
1361
  >
559
1362
  <Image source={{ uri: image }} style={styles.image} onLayout={onImageLayout} />
560
1363
  {/* IMPORTANT: prevent SVG overlay from stealing touch events so dragging works reliably */}