react-native-expo-cropper 1.2.44 → 1.2.46

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,1448 +1,1454 @@
1
- import styles from './ImageCropperStyles';
2
- import React, { useState, useRef, useEffect } from 'react';
3
- import { Modal, View, Image, Dimensions, TouchableOpacity, Animated, Text, Platform, SafeAreaView, PixelRatio, StyleSheet, ActivityIndicator } from 'react-native';
4
- import Svg, { Path, Circle } from 'react-native-svg';
5
- import CustomCamera from './CustomCamera';
6
- import * as ImageManipulator from 'expo-image-manipulator';
7
- import { Ionicons } from '@expo/vector-icons';
8
- import { useSafeAreaInsets } from 'react-native-safe-area-context';
9
- import { applyMaskToImage, MaskView } from './ImageMaskProcessor';
10
-
11
- const PRIMARY_GREEN = '#198754';
12
- const ImageCropper = ({ onConfirm, openCameraFirst, initialImage, addheight, rotationLabel }) => {
13
-
14
-
15
- const [image, setImage] = useState(null);
16
- const [points, setPoints] = useState([]);
17
- const [showResult, setShowResult] = useState(false);
18
- const [showCustomCamera, setShowCustomCamera] = useState(false);
19
- const viewRef = useRef(null);
20
- const maskViewRef = useRef(null); // Ref pour la vue de masque (invisible)
21
- const sourceImageUri = useRef(null); // keep original image URI (full-res) for upload
22
- const cameraFrameData = useRef(null); // ✅ Store green frame coordinates from camera
23
-
24
- // RÉFÉRENTIEL UNIQUE : Wrapper commun 9/16 (identique à CustomCamera)
25
- // Ce wrapper est utilisé dans CustomCamera ET ImageCropper pour garantir pixel-perfect matching
26
- const commonWrapperRef = useRef(null);
27
- const commonWrapperLayout = useRef({ x: 0, y: 0, width: 0, height: 0 });
28
-
29
- // ✅ REFACTORISATION : Séparation claire entre dimensions originales et affichage
30
- // Dimensions réelles de l'image originale (pixels)
31
- const originalImageDimensions = useRef({ width: 0, height: 0 });
32
- // Dimensions et position d'affichage à l'écran (pour le calcul des points de crop)
33
- const displayedImageLayout = useRef({ x: 0, y: 0, width: 0, height: 0 });
34
- // Conserver imageMeasure pour compatibilité avec le code existant (utilisé pour SVG overlay)
35
- const imageMeasure = useRef({ x: 0, y: 0, width: 0, height: 0 });
36
- // imageDisplayRect : Rectangle réel de l'image affichée (quand resizeMode='contain') à l'intérieur du wrapper commun
37
- // C'est la zone l'image est réellement visible (avec letterboxing si nécessaire)
38
- // Les points de crop DOIVENT rester dans cette zone pour éviter de cropper hors de l'image
39
- const imageDisplayRect = useRef({ x: 0, y: 0, width: 0, height: 0 });
40
-
41
- // ✅ COMPATIBILITÉ : displayedContentRect reste pour le code existant, mais pointe vers imageDisplayRect
42
- const displayedContentRect = imageDisplayRect;
43
-
44
- // RÉFÉRENTIEL UNIQUE : Calculer imageDisplayRect à l'intérieur du wrapper commun
45
- // - CAMERA: use "cover" mode → image fills wrapper, imageDisplayRect = full wrapper (same as preview)
46
- // - GALLERY: use "contain" mode imageDisplayRect = letterboxed area
47
- const updateImageDisplayRect = (wrapperWidth, wrapperHeight) => {
48
- const iw = originalImageDimensions.current.width;
49
- const ih = originalImageDimensions.current.height;
50
-
51
- // CAMERA IMAGE: Use full wrapper so green frame and white frame show same content
52
- if (cameraFrameData.current && cameraFrameData.current.greenFrame && wrapperWidth > 0 && wrapperHeight > 0) {
53
- imageDisplayRect.current = { x: 0, y: 0, width: wrapperWidth, height: wrapperHeight };
54
- console.log("✅ Image display rect (COVER mode for camera - full wrapper):", imageDisplayRect.current);
55
- return;
56
- }
57
-
58
- console.log("🔄 updateImageDisplayRect called:", {
59
- originalDimensions: { width: iw, height: ih },
60
- wrapperDimensions: { width: wrapperWidth, height: wrapperHeight }
61
- });
62
-
63
- if (iw > 0 && ih > 0 && wrapperWidth > 0 && wrapperHeight > 0) {
64
- // Calculer comment l'image s'affiche en "contain" dans le wrapper (gallery)
65
- const scale = Math.min(wrapperWidth / iw, wrapperHeight / ih);
66
- const imageDisplayWidth = iw * scale;
67
- const imageDisplayHeight = ih * scale;
68
- const offsetX = (wrapperWidth - imageDisplayWidth) / 2;
69
- const offsetY = (wrapperHeight - imageDisplayHeight) / 2;
70
-
71
- imageDisplayRect.current = {
72
- x: offsetX,
73
- y: offsetY,
74
- width: imageDisplayWidth,
75
- height: imageDisplayHeight,
76
- };
77
- console.log("✅ Image display rect (contain in wrapper) calculated:", {
78
- wrapper: { width: wrapperWidth, height: wrapperHeight },
79
- imageDisplayRect: imageDisplayRect.current,
80
- scale: scale.toFixed(4)
81
- });
82
- return;
83
- }
84
-
85
- if (wrapperWidth > 0 && wrapperHeight > 0) {
86
- imageDisplayRect.current = { x: 0, y: 0, width: wrapperWidth, height: wrapperHeight };
87
- console.log("⚠️ Using wrapper dimensions as fallback (original dimensions not available yet):", imageDisplayRect.current);
88
- } else {
89
- imageDisplayRect.current = { x: 0, y: 0, width: 0, height: 0 };
90
- console.warn("❌ Cannot calculate imageDisplayRect: missing dimensions");
91
- }
92
- };
93
-
94
- // COMPATIBILITÉ : Alias pour le code existant
95
- const updateDisplayedContentRect = updateImageDisplayRect;
96
-
97
- const selectedPointIndex = useRef(null);
98
- const lastTap = useRef(null);
99
-
100
- // CRITICAL: Guard to prevent double crop box initialization
101
- // This ensures crop box is initialized only once, especially for camera images
102
- const hasInitializedCropBox = useRef(false);
103
- const imageSource = useRef(null); // 'camera' | 'gallery' | null
104
-
105
- // ✅ FREE DRAG: Store initial touch position and point position for delta-based movement
106
- const initialTouchPosition = useRef(null); // { x, y } - initial touch position when drag starts
107
- const initialPointPosition = useRef(null); // { x, y } - initial point position when drag starts
108
- const lastTouchPosition = useRef(null); // { x, y } - last touch position for incremental delta calculation
109
-
110
- // ✅ CRITICAL: dragBase stores the VISUAL position (can be overshoot) during drag
111
- // This ensures smooth continuous drag without "dead zones" at boundaries
112
- // dragBase is updated with applied position (overshoot) after each movement
113
- // and is used as base for next delta calculation
114
- const dragBase = useRef(null); // { x, y } - visual position during drag (can be overshoot)
115
-
116
- // NEW APPROACH: touchOffset stores the initial offset between point and touch
117
- // This eliminates delta accumulation issues and "dead zones"
118
- // Once set at drag start, it remains constant throughout the drag
119
- const touchOffset = useRef(null); // { x, y } - offset = pointPosition - touchPosition
120
-
121
- // ✅ Track if point was clamped in previous frame (to detect transition)
122
- const wasClampedLastFrame = useRef({ x: false, y: false });
123
-
124
- // Angle de rotation accumulé (pour éviter les rotations multiples)
125
- const rotationAngle = useRef(0);
126
-
127
- // États pour la vue de masque temporaire
128
- const [maskImageUri, setMaskImageUri] = useState(null);
129
- const [maskPoints, setMaskPoints] = useState([]);
130
- const [maskDimensions, setMaskDimensions] = useState({ width: 0, height: 0 });
131
- const [showMaskView, setShowMaskView] = useState(false);
132
-
133
- const [isLoading, setIsLoading] = useState(false);
134
- const [showFullScreenCapture, setShowFullScreenCapture] = useState(false);
135
- const [isRotating, setIsRotating] = useState(false);
136
- const rotationInProgressRef = useRef(false); // block duplicate taps immediately
137
- const lastValidPosition = useRef(null);
138
- const insets = useSafeAreaInsets();
139
-
140
- // NEW ARCH: mobile does NOT export the final crop.
141
-
142
- // No view-shot / captureRef / bitmap masking on device.
143
- const enableMask = false;
144
- const enableRotation = true; // rotation resets crop box so it is re-initialized on rotated image.
145
-
146
-
147
-
148
-
149
-
150
- useEffect(() => {
151
- if (openCameraFirst) {
152
- setShowCustomCamera(true);
153
- } else if (initialImage) {
154
- setImage(initialImage);
155
- sourceImageUri.current = initialImage;
156
- // CRITICAL: Reset points when loading a new image from gallery
157
- // This ensures the crop box will be automatically initialized
158
- setPoints([]);
159
- rotationAngle.current = 0;
160
- // Clear camera frame data for gallery images
161
- cameraFrameData.current = null;
162
- // CRITICAL: Reset initialization guard for new image
163
- hasInitializedCropBox.current = false;
164
- imageSource.current = null;
165
- }
166
- }, [openCameraFirst, initialImage]);
167
-
168
-
169
- // REFACTORISATION : Stocker uniquement les dimensions originales (pas de calcul théorique)
170
- // ✅ CRITICAL FIX: Single source of truth for image dimensions
171
-
172
- useEffect(() => {
173
- if (!image) {
174
- originalImageDimensions.current = { width: 0, height: 0 };
175
- hasInitializedCropBox.current = false;
176
- imageSource.current = null;
177
- return;
178
- }
179
- if (!sourceImageUri.current) {
180
- sourceImageUri.current = image;
181
- }
182
-
183
- // ✅ CRITICAL FIX #1: If we have capturedImageSize from camera, use it as SINGLE SOURCE OF TRUTH
184
- // takePictureAsync returns physical dimensions, while Image.getSize() may return EXIF-oriented dimensions
185
- // DO NOT call Image.getSize() for camera images - it can return swapped dimensions on Android
186
- if (cameraFrameData.current && cameraFrameData.current.capturedImageSize) {
187
- const { width: capturedWidth, height: capturedHeight } = cameraFrameData.current.capturedImageSize;
188
- originalImageDimensions.current = {
189
- width: capturedWidth,
190
- height: capturedHeight,
191
- };
192
- imageSource.current = 'camera';
193
- hasInitializedCropBox.current = false; // Reset guard for new camera image
194
-
195
- console.log("✅ Using captured image dimensions from takePictureAsync (SINGLE SOURCE OF TRUTH):", {
196
- width: capturedWidth,
197
- height: capturedHeight,
198
- source: 'takePictureAsync',
199
- note: 'Image.getSize() will NOT be called for camera images'
200
- });
201
-
202
- // ✅ CRITICAL: Recalculate imageDisplayRect immediately with camera dimensions
203
- const wrapper = commonWrapperLayout.current;
204
- if (wrapper.width > 0 && wrapper.height > 0) {
205
- updateImageDisplayRect(wrapper.width, wrapper.height);
206
-
207
- // ✅ CRITICAL FIX #2: Initialize crop box immediately when cameraFrameData is available
208
- // This ensures camera images are initialized from greenFrame BEFORE any other initialization
209
- if (cameraFrameData.current && cameraFrameData.current.greenFrame && !hasInitializedCropBox.current) {
210
- console.log("✅ Initializing crop box from cameraFrameData (immediate in useEffect):", {
211
- hasGreenFrame: !!cameraFrameData.current.greenFrame,
212
- wrapper: wrapper,
213
- originalDimensions: originalImageDimensions.current
214
- });
215
- initializeCropBox();
216
- }
217
- }
218
-
219
- return; // ✅ CRITICAL: Exit early - DO NOT call Image.getSize()
220
- }
221
-
222
- // ✅ FALLBACK: Use Image.getSize() ONLY for gallery images (no cameraFrameData)
223
- // BUT: Check again right before calling to avoid race condition
224
- if (cameraFrameData.current && cameraFrameData.current.capturedImageSize) {
225
- console.log("⚠️ cameraFrameData exists, skipping Image.getSize() call");
226
- return;
227
- }
228
-
229
- // ✅ CRITICAL: Also check imageSource - if it's 'camera', don't call Image.getSize()
230
- if (imageSource.current === 'camera') {
231
- console.log("⚠️ imageSource is 'camera', skipping Image.getSize() call");
232
- return;
233
- }
234
-
235
- // ✅ CRITICAL: Set imageSource to 'gallery' ONLY if we're sure it's not a camera image
236
- // Don't set it yet - we'll set it in the callback after verifying
237
- hasInitializedCropBox.current = false; // Reset guard for new image
238
-
239
- Image.getSize(image, (imgWidth, imgHeight) => {
240
- // ✅ CRITICAL SAFETY #1: Check if cameraFrameData appeared while Image.getSize() was resolving
241
- // This is the PRIMARY check - cameraFrameData takes precedence
242
- if (cameraFrameData.current && cameraFrameData.current.capturedImageSize) {
243
- console.warn("⚠️ Image.getSize() resolved but cameraFrameData exists - IGNORING Image.getSize() result to prevent dimension swap");
244
- console.warn("⚠️ Camera dimensions (correct):", cameraFrameData.current.capturedImageSize);
245
- console.warn("⚠️ Image.getSize() dimensions (potentially swapped):", { width: imgWidth, height: imgHeight });
246
- return; // CRITICAL: Exit early - do NOT update dimensions or initialize crop box
247
- }
248
-
249
- // CRITICAL SAFETY #2: Check imageSource (should be 'camera' if cameraFrameData was set)
250
- if (imageSource.current === 'camera') {
251
- console.warn("⚠️ Image.getSize() resolved but imageSource is 'camera' - IGNORING Image.getSize() result");
252
- console.warn("⚠️ Image.getSize() dimensions (potentially swapped):", { width: imgWidth, height: imgHeight });
253
- return; // ✅ CRITICAL: Exit early - do NOT update dimensions or initialize crop box
254
- }
255
-
256
- // CRITICAL SAFETY #3: Check if crop box was already initialized (from camera)
257
- if (hasInitializedCropBox.current) {
258
- console.warn("⚠️ Image.getSize() resolved but crop box already initialized - IGNORING result to prevent double initialization");
259
- return;
260
- }
261
-
262
- // ✅ SAFE: This is a gallery image, proceed with Image.getSize() result
263
- imageSource.current = 'gallery';
264
-
265
- originalImageDimensions.current = {
266
- width: imgWidth,
267
- height: imgHeight,
268
- };
269
-
270
- console.log("✅ Image dimensions from Image.getSize() (gallery image):", {
271
- width: imgWidth,
272
- height: imgHeight,
273
- platform: Platform.OS,
274
- pixelRatio: PixelRatio.get(),
275
- uri: image,
276
- source: 'Image.getSize()'
277
- });
278
-
279
- // ✅ RÉFÉRENTIEL UNIQUE : Recalculer imageDisplayRect dans le wrapper commun
280
- // dès qu'on connaît la taille originale de l'image
281
- const wrapper = commonWrapperLayout.current;
282
- if (wrapper.width > 0 && wrapper.height > 0) {
283
- updateImageDisplayRect(wrapper.width, wrapper.height);
284
- // ✅ IMPORTANT: pour les images de la galerie (pas de cameraFrameData),
285
- // initialiser automatiquement le cadre blanc (70% du wrapper) une fois que
286
- // nous connaissons à la fois le wrapper et les dimensions originales.
287
- // CRITICAL: Guard against double initialization
288
- if (!hasInitializedCropBox.current && points.length === 0 && imageSource.current === 'gallery') {
289
- initializeCropBox();
290
- hasInitializedCropBox.current = true;
291
- }
292
- }
293
- }, (error) => {
294
- console.error("Error getting image size:", error);
295
- });
296
- }, [image]);
297
-
298
-
299
-
300
-
301
- // Le cadre blanc doit être calculé sur le MÊME wrapper que le cadre vert (9/16)
302
- // Ensuite, on restreint les points pour qu'ils restent dans imageDisplayRect (image visible)
303
- const initializeCropBox = () => {
304
- // ✅ CRITICAL FIX #2: Guard against double initialization
305
- if (hasInitializedCropBox.current) {
306
- console.log("⚠️ Crop box already initialized, skipping duplicate initialization");
307
- return;
308
- }
309
-
310
- // CRITICAL: Ensure common wrapper layout is available
311
- const wrapper = commonWrapperLayout.current;
312
- if (wrapper.width === 0 || wrapper.height === 0) {
313
- console.warn("Cannot initialize crop box: common wrapper layout not ready");
314
- return;
315
- }
316
-
317
- // ✅ CRITICAL: Ensure imageDisplayRect is available (zone réelle de l'image dans le wrapper)
318
- let imageRect = imageDisplayRect.current;
319
- if (imageRect.width === 0 || imageRect.height === 0) {
320
- // Recalculer si nécessaire
321
- if (originalImageDimensions.current.width > 0 && originalImageDimensions.current.height > 0) {
322
- updateImageDisplayRect(wrapper.width, wrapper.height);
323
- imageRect = imageDisplayRect.current;
324
- } else {
325
- console.warn("Cannot initialize crop box: imageDisplayRect not available (original dimensions missing)");
326
- return;
327
- }
328
- }
329
-
330
- // CRITICAL FIX: Calculate crop box as percentage of VISIBLE IMAGE AREA (imageDisplayRect)
331
- // NOT the wrapper. This ensures the crop box is truly 80% of the image, not 80% of wrapper then clamped.
332
- // Calculate absolute position of imageDisplayRect within wrapper
333
- const imageRectX = wrapper.x + imageRect.x;
334
- const imageRectY = wrapper.y + imageRect.y;
335
-
336
- // PRIORITY RULE #3: IF image comes from camera Use EXACT green frame coordinates
337
- // Image is displayed in "cover" mode (full wrapper), so green frame coords = white frame coords.
338
- // Store points in WRAPPER-RELATIVE coordinates (greenFrame.x, greenFrame.y) so they match
339
- // touch events (locationX/locationY are relative to the wrapper). This keeps display→image
340
- // conversion correct in Confirm.
341
- if (cameraFrameData.current && cameraFrameData.current.greenFrame && originalImageDimensions.current.width > 0) {
342
- const greenFrame = cameraFrameData.current.greenFrame;
343
-
344
- const boxX = greenFrame.x;
345
- const boxY = greenFrame.y;
346
- const boxWidth = greenFrame.width;
347
- const boxHeight = greenFrame.height;
348
-
349
- // SAFETY: Validate calculated coordinates before creating points
350
- const isValidCoordinate = (val) => typeof val === 'number' && isFinite(val) && !isNaN(val);
351
-
352
- if (!isValidCoordinate(boxX) || !isValidCoordinate(boxY) ||
353
- !isValidCoordinate(boxWidth) || !isValidCoordinate(boxHeight)) {
354
- console.warn("⚠️ Invalid coordinates calculated for crop box, skipping initialization");
355
- return;
356
- }
357
-
358
- // Points in wrapper-relative coords (same as touch events)
359
- const newPoints = [
360
- { x: boxX, y: boxY }, // Top-left
361
- { x: boxX + boxWidth, y: boxY }, // Top-right
362
- { x: boxX + boxWidth, y: boxY + boxHeight }, // Bottom-right
363
- { x: boxX, y: boxY + boxHeight }, // Bottom-left
364
- ];
365
-
366
- // ✅ SAFETY: Validate all points before setting
367
- const validPoints = newPoints.filter(p => isValidCoordinate(p.x) && isValidCoordinate(p.y));
368
- if (validPoints.length !== newPoints.length) {
369
- console.warn("⚠️ Some points have invalid coordinates, skipping initialization");
370
- return;
371
- }
372
-
373
- console.log("✅ Initializing crop box for camera image (COVER MODE - exact green frame):", {
374
- greenFrame: { x: greenFrame.x, y: greenFrame.y, width: greenFrame.width, height: greenFrame.height },
375
- whiteFrame: { x: boxX.toFixed(2), y: boxY.toFixed(2), width: boxWidth.toFixed(2), height: boxHeight.toFixed(2) },
376
- note: "Points in wrapper-relative coords - same as touch events and crop_image_size",
377
- });
378
-
379
- setPoints(newPoints);
380
- hasInitializedCropBox.current = true; // CRITICAL: Mark as initialized
381
- // ✅ CRITICAL: DO NOT nullify cameraFrameData here - keep it for Image.getSize() callback check
382
- // It will be cleared when loading a new image
383
- return;
384
- }
385
-
386
- // ✅ PRIORITY RULE #3: DEFAULT logic ONLY for gallery images (NOT camera)
387
- // If we reach here and imageSource is 'camera', something went wrong
388
- if (imageSource.current === 'camera') {
389
- console.warn("⚠️ Camera image but no greenFrame found - this should not happen");
390
- return;
391
- }
392
-
393
- // ✅ DEFAULT: Crop box (70% of VISIBLE IMAGE AREA - centered) - ONLY for gallery images
394
- const boxWidth = imageRect.width * 0.70; // 70% of visible image width
395
- const boxHeight = imageRect.height * 0.70; // 70% of visible image height
396
- const boxX = imageRectX + (imageRect.width - boxWidth) / 2; // Centered in image area
397
- const boxY = imageRectY + (imageRect.height - boxHeight) / 2; // Centered in image area
398
-
399
- // SAFETY: Validate calculated coordinates before creating points
400
- const isValidCoordinate = (val) => typeof val === 'number' && isFinite(val) && !isNaN(val);
401
-
402
- if (!isValidCoordinate(boxX) || !isValidCoordinate(boxY) ||
403
- !isValidCoordinate(boxWidth) || !isValidCoordinate(boxHeight)) {
404
- console.warn("⚠️ Invalid coordinates calculated for default crop box, skipping initialization");
405
- return;
406
- }
407
-
408
- const newPoints = [
409
- { x: boxX, y: boxY },
410
- { x: boxX + boxWidth, y: boxY },
411
- { x: boxX + boxWidth, y: boxY + boxHeight },
412
- { x: boxX, y: boxY + boxHeight },
413
- ];
414
-
415
- // ✅ SAFETY: Validate all points before setting
416
- const validPoints = newPoints.filter(p => isValidCoordinate(p.x) && isValidCoordinate(p.y));
417
- if (validPoints.length !== newPoints.length) {
418
- console.warn("⚠️ Some points have invalid coordinates in default crop box, skipping initialization");
419
- return;
420
- }
421
-
422
- console.log("✅ Initializing crop box (default - 70% of visible image area, gallery only):", {
423
- wrapper: { width: wrapper.width, height: wrapper.height },
424
- imageDisplayRect: imageRect,
425
- boxInImage: { x: boxX, y: boxY, width: boxWidth, height: boxHeight },
426
- points: newPoints
427
- });
428
-
429
- setPoints(newPoints);
430
- hasInitializedCropBox.current = true; // CRITICAL: Mark as initialized
431
- };
432
-
433
- // ✅ RÉFÉRENTIEL UNIQUE : Callback pour mettre à jour le layout du wrapper commun
434
- // Ce wrapper a exactement les mêmes dimensions que le wrapper de CustomCamera (9/16, width = screenWidth)
435
- const onCommonWrapperLayout = (e) => {
436
- const layout = e.nativeEvent.layout;
437
- commonWrapperLayout.current = {
438
- x: layout.x,
439
- y: layout.y,
440
- width: layout.width,
441
- height: layout.height
442
- };
443
-
444
- console.log("✅ Common wrapper layout updated:", commonWrapperLayout.current);
445
-
446
- // ✅ Recalculer imageDisplayRect dès que le wrapper est prêt
447
- if (originalImageDimensions.current.width > 0 && originalImageDimensions.current.height > 0) {
448
- updateImageDisplayRect(layout.width, layout.height);
449
-
450
- // ✅ CRITICAL FIX #2: Initialize crop box ONLY if not already initialized
451
- // For camera images: initialize ONLY from greenFrame (already done when cameraFrameData was set)
452
- if (!hasInitializedCropBox.current && points.length === 0) {
453
- // ✅ CRITICAL: Only initialize for gallery images here
454
- // Camera images should be initialized when cameraFrameData is set, not here
455
- if (imageSource.current !== 'camera') {
456
- initializeCropBox();
457
- }
458
- }
459
- }
460
- };
461
-
462
- // ✅ REFACTORISATION : Mettre à jour les dimensions d'affichage et les dimensions pour SVG
463
- const onImageLayout = (e) => {
464
- const layout = e.nativeEvent.layout;
465
-
466
- // Stocker les dimensions d'affichage réelles (pour conversion de coordonnées)
467
- displayedImageLayout.current = {
468
- x: layout.x,
469
- y: layout.y,
470
- width: layout.width,
471
- height: layout.height
472
- };
473
-
474
- // Conserver aussi dans imageMeasure pour compatibilité avec SVG overlay
475
- imageMeasure.current = {
476
- x: layout.x,
477
- y: layout.y,
478
- width: layout.width,
479
- height: layout.height
480
- };
481
-
482
- // ✅ Si l'image vient de la caméra et que les dimensions originales ne sont pas encore définies,
483
- // les initialiser à partir de cameraFrameData AVANT de calculer le contentRect.
484
- if (
485
- originalImageDimensions.current.width === 0 &&
486
- cameraFrameData.current &&
487
- cameraFrameData.current.capturedImageSize
488
- ) {
489
- const { width, height } = cameraFrameData.current.capturedImageSize;
490
- originalImageDimensions.current = { width, height };
491
- console.log("✅ originalImageDimensions initialisées depuis cameraFrameData dans onImageLayout:", {
492
- width,
493
- height,
494
- });
495
- }
496
-
497
- // ✅ RÉFÉRENTIEL UNIQUE : Recalculer imageDisplayRect dans le wrapper commun
498
- // Si le wrapper commun n'est pas encore prêt, on attendra onCommonWrapperLayout
499
- const wrapper = commonWrapperLayout.current;
500
- if (wrapper.width > 0 && wrapper.height > 0) {
501
- updateImageDisplayRect(wrapper.width, wrapper.height);
502
- }
503
-
504
- console.log("Displayed image layout updated:", {
505
- width: layout.width,
506
- height: layout.height,
507
- x: layout.x,
508
- y: layout.y
509
- });
510
-
511
- // ✅ CRITICAL FIX #2: Do NOT initialize crop box in onImageLayout for camera images
512
- // Camera images should be initialized ONLY when cameraFrameData is set (in useEffect)
513
- // Gallery images can be initialized here if not already done
514
- if (
515
- wrapper.width > 0 &&
516
- wrapper.height > 0 &&
517
- layout.width > 0 &&
518
- layout.height > 0 &&
519
- !hasInitializedCropBox.current &&
520
- points.length === 0 &&
521
- originalImageDimensions.current.width > 0 &&
522
- originalImageDimensions.current.height > 0
523
- ) {
524
- // ✅ CRITICAL: Only initialize for gallery images here
525
- // Camera images should be initialized when cameraFrameData is set, not here
526
- if (imageSource.current !== 'camera') {
527
- initializeCropBox();
528
- } else if (cameraFrameData.current && cameraFrameData.current.greenFrame) {
529
- // ✅ For camera images, initialize ONLY if greenFrame is available
530
- initializeCropBox();
531
- }
532
- }
533
- };
534
-
535
- const createPath = () => {
536
- if (points.length < 1) return '';
537
- let path = `M ${points[0].x} ${points[0].y} `;
538
- points.forEach(point => path += `L ${point.x} ${point.y} `);
539
- return path + 'Z';
540
- };
541
-
542
- // Helper function: Find closest point on a line segment to a tap point
543
- const findClosestPointOnLine = (tapX, tapY, lineStartX, lineStartY, lineEndX, lineEndY) => {
544
- const dx = lineEndX - lineStartX;
545
- const dy = lineEndY - lineStartY;
546
- const lengthSquared = dx * dx + dy * dy;
547
-
548
- if (lengthSquared === 0) {
549
- // Line segment is a point
550
- return { x: lineStartX, y: lineStartY, distance: Math.sqrt((tapX - lineStartX) ** 2 + (tapY - lineStartY) ** 2) };
551
- }
552
-
553
- // Calculate projection parameter t (0 to 1)
554
- const t = Math.max(0, Math.min(1, ((tapX - lineStartX) * dx + (tapY - lineStartY) * dy) / lengthSquared));
555
-
556
- // Calculate closest point on line segment
557
- const closestX = lineStartX + t * dx;
558
- const closestY = lineStartY + t * dy;
559
-
560
- // Calculate distance from tap to closest point
561
- const distance = Math.sqrt((tapX - closestX) ** 2 + (tapY - closestY) ** 2);
562
-
563
- return { x: closestX, y: closestY, distance, t };
564
- };
565
-
566
- // Helper function: Check if tap is near any line segment and find closest point
567
- const findClosestPointOnFrame = (tapX, tapY, lineTolerance = 30) => {
568
- if (points.length < 2) return null;
569
-
570
- let closestPoint = null;
571
- let minDistance = Infinity;
572
- let insertIndex = -1;
573
-
574
- // Check each line segment (closed polygon: last point connects to first)
575
- for (let i = 0; i < points.length; i++) {
576
- const start = points[i];
577
- const end = points[(i + 1) % points.length];
578
-
579
- const result = findClosestPointOnLine(tapX, tapY, start.x, start.y, end.x, end.y);
580
-
581
- if (result.distance < minDistance && result.distance < lineTolerance) {
582
- minDistance = result.distance;
583
- closestPoint = { x: result.x, y: result.y };
584
- // Insert after the start point of this segment
585
- insertIndex = i + 1;
586
- }
587
- }
588
-
589
- return closestPoint ? { point: closestPoint, insertIndex } : null;
590
- };
591
-
592
- const handleTap = (e) => {
593
- if (!image || showResult) return;
594
- const now = Date.now();
595
- const { locationX: tapX, locationY: tapY } = e.nativeEvent;
596
-
597
- // RÉFÉRENTIEL UNIQUE : Utiliser imageDisplayRect (zone réelle de l'image dans le wrapper)
598
- // Les coordonnées du tap sont relatives au wrapper commun
599
- let imageRect = imageDisplayRect.current;
600
- const wrapper = commonWrapperLayout.current;
601
-
602
- // Recalculate if not available
603
- if (imageRect.width === 0 || imageRect.height === 0) {
604
- if (wrapper.width > 0 && wrapper.height > 0 && originalImageDimensions.current.width > 0) {
605
- updateImageDisplayRect(wrapper.width, wrapper.height);
606
- imageRect = imageDisplayRect.current;
607
- }
608
- // If still not available, use wrapper as fallback
609
- if (imageRect.width === 0 || imageRect.height === 0) {
610
- if (wrapper.width > 0 && wrapper.height > 0) {
611
- imageRect = {
612
- x: wrapper.x,
613
- y: wrapper.y,
614
- width: wrapper.width,
615
- height: wrapper.height
616
- };
617
- } else {
618
- console.warn("⚠️ Cannot handle tap: wrapper or imageDisplayRect not available");
619
- return;
620
- }
621
- }
622
- }
623
-
624
- // ✅ Clamp to real displayed image content (imageDisplayRect dans le wrapper)
625
- // Les coordonnées tapX/tapY sont relatives au wrapper commun
626
- const imageRectX = wrapper.x + imageRect.x;
627
- const imageRectY = wrapper.y + imageRect.y;
628
- const { x: cx, y: cy, width: cw, height: ch } = {
629
- x: imageRectX,
630
- y: imageRectY,
631
- width: imageRect.width,
632
- height: imageRect.height
633
- };
634
- // ✅ Larger select radius for easier point selection (especially on touch screens)
635
- const selectRadius = 50; // Increased from 28 to 50 for better UX
636
-
637
- // ✅ CRITICAL: Check for existing point selection FIRST (using raw tap coordinates)
638
- // Don't clamp tapX/Y for point selection - points can be anywhere in wrapper now
639
- const index = points.findIndex(p => Math.abs(p.x - tapX) < selectRadius && Math.abs(p.y - tapY) < selectRadius);
640
-
641
- if (index !== -1) {
642
- // ✅ Point found - select it for dragging
643
- selectedPointIndex.current = index;
644
-
645
- // Store initial positions
646
- initialTouchPosition.current = { x: tapX, y: tapY };
647
- lastTouchPosition.current = { x: tapX, y: tapY };
648
- initialPointPosition.current = { ...points[index] };
649
- lastValidPosition.current = { ...points[index] };
650
-
651
- // Calculate offset between point and touch at drag start
652
- touchOffset.current = {
653
- x: points[index].x - tapX,
654
- y: points[index].y - tapY
655
- };
656
-
657
- console.log("🎯 DRAG START - Offset calculated:", {
658
- pointX: points[index].x.toFixed(2),
659
- pointY: points[index].y.toFixed(2),
660
- touchX: tapX.toFixed(2),
661
- touchY: tapY.toFixed(2),
662
- offsetX: touchOffset.current.x.toFixed(2),
663
- offsetY: touchOffset.current.y.toFixed(2)
664
- });
665
-
666
- // Disable parent ScrollView scrolling when dragging
667
- try {
668
- const findScrollView = (node) => {
669
- if (!node) return null;
670
- if (node._component && node._component.setNativeProps) {
671
- node._component.setNativeProps({ scrollEnabled: false });
672
- }
673
- return findScrollView(node._owner || node._parent);
674
- };
675
- } catch (e) {
676
- // Ignore errors
677
- }
678
- } else {
679
- // ✅ No point found - check if double-tap on a line to create new point
680
- const isDoubleTap = lastTap.current && now - lastTap.current < 300;
681
-
682
- if (isDoubleTap && points.length >= 2) {
683
- // Find closest point on frame lines
684
- const lineResult = findClosestPointOnFrame(tapX, tapY, 30); // 30px tolerance
685
-
686
- if (lineResult) {
687
- const { point, insertIndex } = lineResult;
688
-
689
- // Check if a point already exists very close to this position
690
- const exists = points.some(p => Math.abs(p.x - point.x) < selectRadius && Math.abs(p.y - point.y) < selectRadius);
691
-
692
- if (!exists) {
693
- // Insert new point at the correct position in the polygon
694
- const newPoints = [...points];
695
- newPoints.splice(insertIndex, 0, point);
696
- setPoints(newPoints);
697
-
698
- console.log("✅ New point created on frame line:", {
699
- tap: { x: tapX.toFixed(2), y: tapY.toFixed(2) },
700
- newPoint: { x: point.x.toFixed(2), y: point.y.toFixed(2) },
701
- insertIndex,
702
- totalPoints: newPoints.length
703
- });
704
-
705
- lastTap.current = null; // Reset to prevent triple-tap
706
- return;
707
- }
708
- }
709
- }
710
- }
711
-
712
- lastTap.current = now;
713
- };
714
-
715
- const handleMove = (e) => {
716
- if (showResult || selectedPointIndex.current === null) return;
717
-
718
- // ✅ FREE DRAG: Use delta-based movement for smooth, unconstrained dragging
719
-
720
- const nativeEvent = e.nativeEvent;
721
- const currentX = nativeEvent.locationX;
722
- const currentY = nativeEvent.locationY;
723
-
724
- // ✅ Validate coordinates
725
- if (currentX === undefined || currentY === undefined || isNaN(currentX) || isNaN(currentY)) {
726
- console.warn("⚠️ Cannot get touch coordinates", {
727
- locationX: nativeEvent.locationX,
728
- locationY: nativeEvent.locationY
729
- });
730
- return;
731
- }
732
-
733
- // This is more reliable when ScrollView affects coordinate updates
734
- let deltaX, deltaY;
735
- if (lastTouchPosition.current) {
736
- // Calculate incremental delta from last touch position
737
- deltaX = currentX - lastTouchPosition.current.x;
738
- deltaY = currentY - lastTouchPosition.current.y;
739
- } else if (initialTouchPosition.current) {
740
- // Fallback to absolute delta if lastTouchPosition not set
741
- deltaX = currentX - initialTouchPosition.current.x;
742
- deltaY = currentY - initialTouchPosition.current.y;
743
- } else {
744
- console.warn("⚠️ No touch position reference available");
745
- return;
746
- }
747
-
748
- // Les coordonnées de mouvement sont relatives au wrapper commun
749
- let imageRect = imageDisplayRect.current;
750
- const wrapper = commonWrapperLayout.current;
751
-
752
- // Recalculate if not available
753
- if (imageRect.width === 0 || imageRect.height === 0) {
754
- if (wrapper.width > 0 && wrapper.height > 0 && originalImageDimensions.current.width > 0) {
755
- updateImageDisplayRect(wrapper.width, wrapper.height);
756
- imageRect = imageDisplayRect.current;
757
- }
758
- // If still not available, use wrapper as fallback
759
- if (imageRect.width === 0 || imageRect.height === 0) {
760
- if (wrapper.width > 0 && wrapper.height > 0) {
761
- imageRect = {
762
- x: wrapper.x,
763
- y: wrapper.y,
764
- width: wrapper.width,
765
- height: wrapper.height
766
- };
767
- } else {
768
- console.warn("⚠️ Cannot move point: wrapper or imageDisplayRect not available");
769
- return;
770
- }
771
- }
772
- }
773
-
774
- // ✅ CRITICAL: Calculate absolute bounds of imageDisplayRect within the wrapper
775
- // imageRect is relative to wrapper, so we need to add wrapper offset
776
- const imageRectX = wrapper.x + imageRect.x;
777
- const imageRectY = wrapper.y + imageRect.y;
778
- const contentRect = {
779
- x: imageRectX,
780
- y: imageRectY,
781
- width: imageRect.width,
782
- height: imageRect.height
783
- };
784
-
785
- // ✅ FREE DRAG: Ensure initial positions are set
786
- if (!initialPointPosition.current) {
787
- const currentPoint = points[selectedPointIndex.current];
788
- if (currentPoint && typeof currentPoint.x === 'number' && typeof currentPoint.y === 'number') {
789
- initialPointPosition.current = { ...currentPoint };
790
- } else {
791
- console.warn("⚠️ No point found for selected index or invalid point data");
792
- return;
793
- }
794
- }
795
-
796
- // NEW APPROACH: Use touchOffset to map touch position directly to point position
797
- // This eliminates delta accumulation and "dead zone" issues completely
798
- if (!touchOffset.current) {
799
- console.warn("⚠️ touchOffset not initialized, cannot move point");
800
- return;
801
- }
802
-
803
- // DIRECT MAPPING: newPosition = touchPosition + offset
804
- // No delta accumulation, no zone morte
805
- const newX = currentX + touchOffset.current.x;
806
- const newY = currentY + touchOffset.current.y;
807
-
808
- // ✅ SEPARATE DRAG BOUNDS vs CROP BOUNDS
809
- const { x: cx, y: cy, width: cw, height: ch } = contentRect;
810
-
811
- // STRICT BOUNDS: For final crop safety (imageDisplayRect)
812
- const strictMinX = cx;
813
- const strictMaxX = cx + cw;
814
- const strictMinY = cy;
815
- const strictMaxY = cy + ch;
816
-
817
- // DRAG BOUNDS: Allow movement ANYWHERE in wrapper during drag
818
- // Points can move freely across the entire screen for maximum flexibility
819
- // They will be clamped to imageDisplayRect only on release for safe cropping
820
- const wrapperRect = wrapper;
821
- const overshootMinX = wrapperRect.x;
822
- const overshootMaxX = wrapperRect.x + wrapperRect.width;
823
- const overshootMinY = wrapperRect.y;
824
- const overshootMaxY = wrapperRect.y + wrapperRect.height;
825
-
826
- // DRAG BOUNDS: Clamp ONLY to overshootBounds during drag (NOT strictBounds)
827
- const dragX = Math.max(overshootMinX, Math.min(newX, overshootMaxX));
828
- const dragY = Math.max(overshootMinY, Math.min(newY, overshootMaxY));
829
-
830
- // ✅ UPDATE POINT: Use drag bounds (overshoot) - allows visual freedom
831
- const updatedPoint = { x: dragX, y: dragY };
832
-
833
- // CRITICAL: Detect if point is AT overshoot boundary (not just clamped)
834
- // Check if point is exactly at overshootMin/Max (within 1px tolerance)
835
- const isAtOvershootMinX = Math.abs(dragX - overshootMinX) < 1;
836
- const isAtOvershootMaxX = Math.abs(dragX - overshootMaxX) < 1;
837
- const isAtOvershootMinY = Math.abs(dragY - overshootMinY) < 1;
838
- const isAtOvershootMaxY = Math.abs(dragY - overshootMaxY) < 1;
839
-
840
- const isAtBoundaryX = isAtOvershootMinX || isAtOvershootMaxX;
841
- const isAtBoundaryY = isAtOvershootMinY || isAtOvershootMaxY;
842
-
843
- // Only recalculate offset when FIRST hitting boundary (transition free → boundary)
844
- const justHitBoundaryX = isAtBoundaryX && !wasClampedLastFrame.current.x;
845
- const justHitBoundaryY = isAtBoundaryY && !wasClampedLastFrame.current.y;
846
-
847
- if (justHitBoundaryX || justHitBoundaryY) {
848
- // Point JUST hit overshoot boundary - recalculate offset once
849
- const newOffsetX = justHitBoundaryX ? (dragX - currentX) : touchOffset.current.x;
850
- const newOffsetY = justHitBoundaryY ? (dragY - currentY) : touchOffset.current.y;
851
-
852
- touchOffset.current = {
853
- x: newOffsetX,
854
- y: newOffsetY
855
- };
856
-
857
- console.log("✅ OFFSET RECALCULATED (hit boundary):", {
858
- axis: justHitBoundaryX ? 'X' : 'Y',
859
- touchY: currentY.toFixed(2),
860
- dragY: dragY.toFixed(2),
861
- newOffsetY: touchOffset.current.y.toFixed(2),
862
- note: "First contact with boundary - offset locked"
863
- });
864
- }
865
-
866
- // Update boundary state for next frame
867
- wasClampedLastFrame.current = { x: isAtBoundaryX, y: isAtBoundaryY };
868
-
869
- // ✅ DEBUG: Log when in overshoot zone (only when not at boundary)
870
- const isInOvershootY = dragY < strictMinY || dragY > strictMaxY;
871
- if (isInOvershootY && !isAtBoundaryY) {
872
- console.log("🎯 IN OVERSHOOT ZONE:", {
873
- touchY: currentY.toFixed(2),
874
- appliedY: dragY.toFixed(2),
875
- overshootRange: `${overshootMinY.toFixed(2)} - ${overshootMaxY.toFixed(2)}`,
876
- strictRange: `${strictMinY.toFixed(2)} - ${strictMaxY.toFixed(2)}`
877
- });
878
- }
879
-
880
- // Update lastValidPosition ONLY if point is within strictBounds
881
- const isStrictlyValid =
882
- dragX >= strictMinX && dragX <= strictMaxX &&
883
- dragY >= strictMinY && dragY <= strictMaxY;
884
-
885
- if (isStrictlyValid) {
886
- lastValidPosition.current = updatedPoint;
887
- }
888
-
889
- // ✅ Update lastTouchPosition for next frame (simple tracking)
890
- lastTouchPosition.current = { x: currentX, y: currentY };
891
-
892
- // ✅ DEBUG: Log the point update before setPoints
893
- console.log("📍 UPDATING POINT:", {
894
- index: selectedPointIndex.current,
895
- newX: updatedPoint.x.toFixed(2),
896
- newY: updatedPoint.y.toFixed(2),
897
- touchX: currentX.toFixed(2),
898
- touchY: currentY.toFixed(2),
899
- offsetX: touchOffset.current.x.toFixed(2),
900
- offsetY: touchOffset.current.y.toFixed(2)
901
- });
902
-
903
- setPoints(prev => {
904
- // ✅ SAFETY: Ensure prev is a valid array
905
- if (!Array.isArray(prev) || prev.length === 0) {
906
- return prev;
907
- }
908
-
909
- const pointIndex = selectedPointIndex.current;
910
- // SAFETY: Validate pointIndex
911
- if (pointIndex === null || pointIndex === undefined || pointIndex < 0 || pointIndex >= prev.length) {
912
- return prev;
913
- }
914
-
915
- // ✅ SAFETY: Filter out any invalid points and update the selected one
916
- const newPoints = prev.map((p, i) => {
917
- if (i === pointIndex) {
918
- return updatedPoint;
919
- }
920
- // ✅ SAFETY: Ensure existing points are valid
921
- if (p && typeof p.x === 'number' && typeof p.y === 'number') {
922
- return p;
923
- }
924
- // If point is invalid, return a default point (shouldn't happen, but safety first)
925
- return { x: 0, y: 0 };
926
- });
927
-
928
- // ✅ DEBUG: Log the state update
929
- console.log("✅ STATE UPDATED:", {
930
- index: pointIndex,
931
- oldY: prev[pointIndex]?.y.toFixed(2),
932
- newY: newPoints[pointIndex]?.y.toFixed(2),
933
- changed: Math.abs(prev[pointIndex]?.y - newPoints[pointIndex]?.y) > 0.01
934
- });
935
-
936
- return newPoints;
937
- });
938
- };
939
-
940
- const handleRelease = () => {
941
- const wasDragging = selectedPointIndex.current !== null;
942
-
943
- // ✅ CRITICAL: Reset drag state when drag ends
944
- touchOffset.current = null;
945
- wasClampedLastFrame.current = { x: false, y: false };
946
-
947
- // ✅ VISUAL OVERSHOOT: Clamp points back to imageDisplayRect when drag ends
948
- // This ensures final crop is always within valid image bounds
949
- if (wasDragging && selectedPointIndex.current !== null) {
950
- const wrapper = commonWrapperLayout.current;
951
- let imageRect = imageDisplayRect.current;
952
-
953
- // Recalculate imageDisplayRect if needed
954
- if (imageRect.width === 0 || imageRect.height === 0) {
955
- if (wrapper.width > 0 && wrapper.height > 0 && originalImageDimensions.current.width > 0) {
956
- updateImageDisplayRect(wrapper.width, wrapper.height);
957
- imageRect = imageDisplayRect.current;
958
- }
959
- }
960
-
961
- if (imageRect.width > 0 && imageRect.height > 0) {
962
- const imageRectX = wrapper.x + imageRect.x;
963
- const imageRectY = wrapper.y + imageRect.y;
964
- const imageRectMaxX = imageRectX + imageRect.width;
965
- const imageRectMaxY = imageRectY + imageRect.height;
966
-
967
- // Clamp the dragged point back to strict image bounds
968
- setPoints(prev => {
969
- // SAFETY: Ensure prev is a valid array
970
- if (!Array.isArray(prev) || prev.length === 0) {
971
- return prev;
972
- }
973
-
974
- const pointIndex = selectedPointIndex.current;
975
- // SAFETY: Validate pointIndex and ensure point exists
976
- if (pointIndex === null || pointIndex === undefined || pointIndex < 0 || pointIndex >= prev.length) {
977
- return prev;
978
- }
979
-
980
- const point = prev[pointIndex];
981
- // SAFETY: Ensure point exists and has valid x/y properties
982
- if (!point || typeof point.x !== 'number' || typeof point.y !== 'number') {
983
- return prev;
984
- }
985
-
986
- const clampedPoint = {
987
- x: Math.max(imageRectX, Math.min(point.x, imageRectMaxX)),
988
- y: Math.max(imageRectY, Math.min(point.y, imageRectMaxY))
989
- };
990
-
991
- // Only update if point was outside bounds
992
- if (point.x !== clampedPoint.x || point.y !== clampedPoint.y) {
993
- console.log("🔒 Clamping point back to image bounds on release:", {
994
- before: { x: point.x.toFixed(2), y: point.y.toFixed(2) },
995
- after: { x: clampedPoint.x.toFixed(2), y: clampedPoint.y.toFixed(2) },
996
- bounds: { minX: imageRectX.toFixed(2), maxX: imageRectMaxX.toFixed(2), minY: imageRectY.toFixed(2), maxY: imageRectMaxY.toFixed(2) }
997
- });
998
-
999
- return prev.map((p, i) => i === pointIndex ? clampedPoint : p);
1000
- }
1001
-
1002
- return prev;
1003
- });
1004
- }
1005
- }
1006
-
1007
- // ✅ FREE DRAG: Clear initial positions when drag ends
1008
- initialTouchPosition.current = null;
1009
- initialPointPosition.current = null;
1010
- lastValidPosition.current = null;
1011
- selectedPointIndex.current = null;
1012
-
1013
- // CRITICAL: Re-enable parent ScrollView scrolling when drag ends
1014
- if (wasDragging) {
1015
- try {
1016
- // Re-enable scrolling after a short delay to avoid conflicts
1017
- setTimeout(() => {
1018
- // ScrollView will be re-enabled automatically when responder is released
1019
- }, 100);
1020
- } catch (e) {
1021
- // Ignore errors
1022
- }
1023
- }
1024
- };
1025
-
1026
- const handleReset = () => {
1027
- // setPoints([]);
1028
- hasInitializedCropBox.current = false; // ✅ CRITICAL: Reset guard to allow reinitialization
1029
- initializeCropBox();
1030
- };
1031
-
1032
- // ✅ REFACTORISATION : Stocker l'angle de rotation au lieu de modifier l'image immédiatement
1033
- // La rotation sera appliquée uniquement lors du crop final pour éviter les interpolations multiples
1034
- const rotatePreviewImage = async (degrees) => {
1035
- if (!image) return;
1036
- if (rotationInProgressRef.current) return; // block duplicate taps immediately (no re-render delay)
1037
- rotationInProgressRef.current = true;
1038
- setIsRotating(true);
1039
-
1040
- try {
1041
- rotationAngle.current = (rotationAngle.current + degrees) % 360;
1042
-
1043
- // Use JPEG for preview rotation (faster than PNG for large images; quality 0.92 is fine for preview)
1044
- const rotated = await ImageManipulator.manipulateAsync(
1045
- image,
1046
- [{ rotate: degrees }],
1047
- {
1048
- compress: 0.92,
1049
- format: ImageManipulator.SaveFormat.JPEG,
1050
- }
1051
- );
1052
-
1053
- // ✅ Send rotated image to backend: use rotated URI and dimensions so crop bbox matches
1054
- sourceImageUri.current = rotated.uri;
1055
- originalImageDimensions.current = {
1056
- width: rotated.width,
1057
- height: rotated.height,
1058
- };
1059
- cameraFrameData.current = null; // rotated image is no longer "camera preview" frame
1060
- imageSource.current = 'gallery'; // so layout callbacks run initializeCropBox() and show the white box
1061
-
1062
- setPoints([]);
1063
- hasInitializedCropBox.current = false;
1064
- setImage(rotated.uri);
1065
- console.log("Rotation applied:", degrees, "degrees; accumulated:", rotationAngle.current);
1066
- } catch (error) {
1067
- console.error("Error rotating image:", error);
1068
- alert("Error rotating image");
1069
- } finally {
1070
- rotationInProgressRef.current = false;
1071
- setIsRotating(false);
1072
- }
1073
- };
1074
-
1075
- // Helper function to wait for multiple render cycles (works on iOS)
1076
- const waitForRender = (cycles = 5) => {
1077
- return new Promise((resolve) => {
1078
- let count = 0;
1079
- const tick = () => {
1080
- count++;
1081
- if (count >= cycles) {
1082
- resolve();
1083
- } else {
1084
- setImmediate(tick);
1085
- }
1086
- };
1087
- setImmediate(tick);
1088
- });
1089
- };
1090
-
1091
-
1092
- return (
1093
- <View style={styles.container}>
1094
-
1095
- {showCustomCamera ? (
1096
- <CustomCamera
1097
- onPhotoCaptured={(uri, frameData) => {
1098
- // ✅ CRITICAL FIX: Store green frame coordinates for coordinate conversion
1099
- if (frameData && frameData.greenFrame) {
1100
- cameraFrameData.current = {
1101
- greenFrame: frameData.greenFrame,
1102
- capturedImageSize: frameData.capturedImageSize
1103
- };
1104
- // CRITICAL: Set imageSource to 'camera' IMMEDIATELY to prevent Image.getSize() from being called
1105
- imageSource.current = 'camera';
1106
- hasInitializedCropBox.current = false; // Reset guard for new camera image
1107
- console.log("✅ Camera frame data received:", cameraFrameData.current);
1108
- }
1109
- setImage(uri);
1110
- setShowCustomCamera(false);
1111
- // CORRECTION : Réinitialiser les points et l'angle de rotation quand une nouvelle photo est capturée
1112
- setPoints([]);
1113
- rotationAngle.current = 0;
1114
- // ✅ CRITICAL: initializeCropBox will be called automatically when image layout is ready
1115
- // The green frame coordinates are stored in cameraFrameData.current and will be used
1116
- }}
1117
- onCancel={() => setShowCustomCamera(false)}
1118
- />
1119
- ) : (
1120
- <>
1121
- {image && (
1122
- <View
1123
- style={{
1124
- width: Dimensions.get('window').width,
1125
- aspectRatio: 9 / 16,
1126
- borderRadius: 30,
1127
- overflow: 'hidden',
1128
- alignItems: 'center',
1129
- justifyContent: 'center',
1130
- position: 'relative',
1131
- backgroundColor: 'black',
1132
- marginBottom: 0, // ✅ Les boutons sont maintenant en position absolue en bas
1133
- }}
1134
- ref={commonWrapperRef}
1135
- onLayout={onCommonWrapperLayout}
1136
- >
1137
- <View
1138
- ref={viewRef}
1139
- collapsable={false}
1140
- style={StyleSheet.absoluteFill}
1141
- onStartShouldSetResponder={() => true}
1142
- onMoveShouldSetResponder={(evt, gestureState) => {
1143
- // ✅ CRITICAL: Always capture movement when a point is selected
1144
- // This ensures vertical movement is captured correctly
1145
- if (selectedPointIndex.current !== null) {
1146
- return true;
1147
- }
1148
- // ✅ CRITICAL: Capture ANY movement immediately (even 0px) to prevent ScrollView interception
1149
- // This is especially important for vertical movement which ScrollView tries to intercept
1150
- // We return true for ANY movement to ensure we capture it before ScrollView
1151
- const hasMovement = Math.abs(gestureState.dx) > 0 || Math.abs(gestureState.dy) > 0;
1152
- if (hasMovement && Math.abs(gestureState.dy) > 5) {
1153
- console.log("🔄 Vertical movement detected in responder:", {
1154
- dx: gestureState.dx.toFixed(2),
1155
- dy: gestureState.dy.toFixed(2),
1156
- selectedPoint: selectedPointIndex.current
1157
- });
1158
- }
1159
- return true;
1160
- }}
1161
- onResponderGrant={(e) => {
1162
- // ✅ CRITICAL: Grant responder immediately to prevent ScrollView from intercepting
1163
- // This ensures we capture all movement, especially vertical
1164
- // Handle tap to select point if needed
1165
- if (selectedPointIndex.current === null) {
1166
- handleTap(e);
1167
- }
1168
- }}
1169
- onResponderStart={handleTap}
1170
- onResponderMove={(e) => {
1171
- // ✅ CRITICAL: Always handle move events to ensure smooth movement in all directions
1172
- // This is called for every move event, ensuring vertical movement is captured
1173
- // handleMove now uses incremental delta calculation which is more reliable
1174
- handleMove(e);
1175
- }}
1176
- onResponderRelease={handleRelease}
1177
- onResponderTerminationRequest={() => {
1178
- // CRITICAL: Never allow termination when dragging a point
1179
- // This prevents ScrollView from stealing the responder during vertical movement
1180
- return selectedPointIndex.current === null;
1181
- }}
1182
- // CRITICAL: Prevent parent ScrollView from intercepting touches
1183
- // Capture responder BEFORE parent ScrollView can intercept
1184
- onStartShouldSetResponderCapture={() => {
1185
- // Always capture start events
1186
- return true;
1187
- }}
1188
- onMoveShouldSetResponderCapture={(evt, gestureState) => {
1189
- // CRITICAL: Always capture movement events before parent ScrollView
1190
- // This is essential for vertical movement which ScrollView tries to intercept
1191
- // Especially important when a point is selected or when there's any movement
1192
- if (selectedPointIndex.current !== null) {
1193
- return true;
1194
- }
1195
- // CRITICAL: Capture movement BEFORE ScrollView can intercept
1196
- // This ensures we get vertical movement even if ScrollView tries to steal it
1197
- const hasMovement = Math.abs(gestureState.dx) > 0 || Math.abs(gestureState.dy) > 0;
1198
- return hasMovement;
1199
- }}
1200
- >
1201
- <Image
1202
- source={{ uri: image }}
1203
- style={styles.image}
1204
- resizeMode={cameraFrameData.current?.greenFrame ? 'cover' : 'contain'}
1205
- onLayout={onImageLayout}
1206
- />
1207
- {/* ✅ RÉFÉRENTIEL UNIQUE : SVG overlay utilise les dimensions du wrapper commun */}
1208
- {/* IMPORTANT: prevent SVG overlay from stealing touch events so dragging works reliably */}
1209
- <Svg style={styles.overlay} pointerEvents="none">
1210
- {(() => {
1211
- // ✅ Use wrapper dimensions for SVG path (wrapper coordinates)
1212
- const wrapperWidth = commonWrapperLayout.current.width || Dimensions.get('window').width;
1213
- const wrapperHeight = commonWrapperLayout.current.height || (Dimensions.get('window').width * 16 / 9);
1214
- return (
1215
- <>
1216
- <Path
1217
- d={`M 0 0 H ${wrapperWidth} V ${wrapperHeight} H 0 Z ${createPath()}`}
1218
- fill={showResult ? 'white' : 'rgba(0, 0, 0, 0.8)'}
1219
- fillRule="evenodd"
1220
- />
1221
- {!showResult && points.length > 0 && (
1222
- <Path d={createPath()} fill="transparent" stroke="white" strokeWidth={2} />
1223
- )}
1224
- {!showResult && points.map((point, index) => (
1225
- <Circle key={index} cx={point.x} cy={point.y} r={10} fill="white" />
1226
- ))}
1227
- </>
1228
- );
1229
- })()}
1230
- </Svg>
1231
- </View>
1232
- {isRotating && (
1233
- <View style={{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'center', alignItems: 'center' }}>
1234
- <ActivityIndicator size="large" color={PRIMARY_GREEN} />
1235
- <Text style={{ color: PRIMARY_GREEN, marginTop: 8, fontSize: 14 }}>{rotationLabel ?? 'Rotation...'}</Text>
1236
- </View>
1237
- )}
1238
- </View>
1239
- )}
1240
-
1241
- {/* Buttons positioned BELOW the image, not overlapping */}
1242
- {!showResult && image && (
1243
- <View style={[styles.buttonContainerBelow, { paddingBottom: Math.max(insets.bottom, 16) }]}>
1244
- {Platform.OS === 'android' && (
1245
- <TouchableOpacity
1246
- style={[styles.rotationButton, isRotating && { opacity: 0.7 }]}
1247
- onPress={() => enableRotation && rotatePreviewImage(90)}
1248
- disabled={isRotating}
1249
- >
1250
- {isRotating ? (
1251
- <ActivityIndicator size="small" color="white" />
1252
- ) : (
1253
- <Ionicons name="sync" size={24} color="white" />
1254
- )}
1255
- </TouchableOpacity>
1256
- )}
1257
-
1258
- <TouchableOpacity style={styles.button} onPress={handleReset}>
1259
- <Text style={styles.buttonText}>Reset</Text>
1260
- </TouchableOpacity>
1261
-
1262
- <TouchableOpacity
1263
- style={styles.button}
1264
- onPress={async () => {
1265
- setIsLoading(true);
1266
- try {
1267
- console.log("=== Starting pixel-perfect metadata export (no bitmap crop on mobile) ===");
1268
-
1269
- let actualImageWidth = originalImageDimensions.current.width;
1270
- let actualImageHeight = originalImageDimensions.current.height;
1271
-
1272
- // ✅ CRITICAL: Camera JPEGs often have EXIF 6 (90° CW). The Image component displays
1273
- // the EXIF-corrected view (3120×4160 portrait) but takePictureAsync returns raw (4160×3120).
1274
- // Use swapped dimensions for coordinate conversion so bbox matches what the user sees.
1275
- const isCoverMode = !!(cameraFrameData.current && cameraFrameData.current.greenFrame);
1276
- const captured = cameraFrameData.current?.capturedImageSize;
1277
- if (isCoverMode && captured && captured.width > captured.height) {
1278
- actualImageWidth = captured.height;
1279
- actualImageHeight = captured.width;
1280
- console.log("✅ Using EXIF-swapped dimensions for bbox (raw was landscape, display is portrait):", {
1281
- raw: { w: captured.width, h: captured.height },
1282
- display: { w: actualImageWidth, h: actualImageHeight },
1283
- });
1284
- }
1285
-
1286
- if (actualImageWidth === 0 || actualImageHeight === 0) {
1287
- throw new Error("Original image dimensions not available. Please wait for image to load.");
1288
- }
1289
-
1290
- const layout = displayedImageLayout.current;
1291
- if (layout.width > 0 && layout.height > 0) {
1292
- updateDisplayedContentRect(layout.width, layout.height);
1293
- }
1294
-
1295
- let contentRect = displayedContentRect.current;
1296
- let displayedWidth = contentRect.width;
1297
- let displayedHeight = contentRect.height;
1298
-
1299
- if (displayedWidth === 0 || displayedHeight === 0) {
1300
- if (layout.width > 0 && layout.height > 0) {
1301
- contentRect = {
1302
- x: layout.x,
1303
- y: layout.y,
1304
- width: layout.width,
1305
- height: layout.height
1306
- };
1307
- displayedWidth = contentRect.width;
1308
- displayedHeight = contentRect.height;
1309
- displayedContentRect.current = contentRect;
1310
- } else {
1311
- throw new Error("Displayed image dimensions not available.");
1312
- }
1313
- }
1314
-
1315
- let scale, coverOffsetX = 0, coverOffsetY = 0;
1316
- if (isCoverMode) {
1317
- scale = Math.max(displayedWidth / actualImageWidth, displayedHeight / actualImageHeight);
1318
- const scaledWidth = actualImageWidth * scale;
1319
- const scaledHeight = actualImageHeight * scale;
1320
- coverOffsetX = (scaledWidth - displayedWidth) / 2;
1321
- coverOffsetY = (scaledHeight - displayedHeight) / 2;
1322
- } else {
1323
- scale = actualImageWidth / displayedWidth;
1324
- }
1325
-
1326
- const originalUri = sourceImageUri.current || image;
1327
- let cropMeta = null;
1328
-
1329
- if (points.length > 0) {
1330
- try {
1331
- const imagePoints = points.map(point => {
1332
- let clampedX, clampedY, origX, origY;
1333
- if (isCoverMode) {
1334
- clampedX = Math.max(0, Math.min(point.x, contentRect.width));
1335
- clampedY = Math.max(0, Math.min(point.y, contentRect.height));
1336
- origX = (clampedX + coverOffsetX) / scale;
1337
- origY = (clampedY + coverOffsetY) / scale;
1338
- } else {
1339
- clampedX = Math.max(contentRect.x, Math.min(point.x, contentRect.x + contentRect.width));
1340
- clampedY = Math.max(contentRect.y, Math.min(point.y, contentRect.y + contentRect.height));
1341
- origX = (clampedX - contentRect.x) * scale;
1342
- origY = (clampedY - contentRect.y) * scale;
1343
- }
1344
- const finalX = Math.max(0, Math.min(origX, actualImageWidth));
1345
- const finalY = Math.max(0, Math.min(origY, actualImageHeight));
1346
- return { x: finalX, y: finalY };
1347
- });
1348
-
1349
- const minX = Math.min(...imagePoints.map(p => p.x));
1350
- const minY = Math.min(...imagePoints.map(p => p.y));
1351
- const maxX = Math.max(...imagePoints.map(p => p.x));
1352
- const maxY = Math.max(...imagePoints.map(p => p.y));
1353
-
1354
- const cropX = Math.max(0, Math.floor(minX));
1355
- const cropY = Math.max(0, Math.floor(minY));
1356
- const cropEndX = Math.min(actualImageWidth, Math.ceil(maxX));
1357
- const cropEndY = Math.min(actualImageHeight, Math.ceil(maxY));
1358
- const cropWidth = Math.max(0, cropEndX - cropX);
1359
- const cropHeight = Math.max(0, cropEndY - cropY);
1360
-
1361
- if (cropWidth > 0 && cropHeight > 0) {
1362
- const bbox = { x: cropX, y: cropY, width: cropWidth, height: cropHeight };
1363
- const polygon = imagePoints.map(point => ({
1364
- x: point.x - cropX,
1365
- y: point.y - cropY
1366
- }));
1367
- cropMeta = {
1368
- bbox,
1369
- polygon,
1370
- rotation: 0,
1371
- imageSize: { width: actualImageWidth, height: actualImageHeight },
1372
- };
1373
- }
1374
- } catch (cropError) {
1375
- console.error("Error computing crop meta:", cropError);
1376
- }
1377
- }
1378
-
1379
- const name = `IMAGE XTK${Date.now()}`;
1380
- if (onConfirm) {
1381
- onConfirm(originalUri, name, cropMeta);
1382
- }
1383
- } catch (error) {
1384
- console.error("Erreur lors du crop :", error);
1385
- alert("Erreur lors du crop ! " + error.message);
1386
- } finally {
1387
- setShowResult(false);
1388
- setIsLoading(false);
1389
- setShowFullScreenCapture(false);
1390
- }
1391
- }}
1392
- >
1393
- <Text style={styles.buttonText}>Confirm</Text>
1394
- </TouchableOpacity>
1395
- </View>
1396
- )}
1397
-
1398
- {/* ✅ Show welcome screen when no image */}
1399
- {!showResult && !image && (
1400
- <View style={styles.centerButtonsContainer}>
1401
- <Text style={styles.welcomeText}>Sélectionnez une image</Text>
1402
- </View>
1403
- )}
1404
- </>
1405
- )}
1406
-
1407
- {/* CORRECTION : Vue de masque temporaire pour la capture
1408
- Cette vue est rendue hors écran mais nécessaire pour captureRef
1409
- Utiliser une position négative mais pas trop éloignée pour éviter les problèmes de captureRef */}
1410
- {showMaskView && maskImageUri && maskPoints.length > 0 && (
1411
- <View
1412
- style={{
1413
- position: 'absolute',
1414
- left: -maskDimensions.width - 100,
1415
- top: -maskDimensions.height - 100,
1416
- width: maskDimensions.width,
1417
- height: maskDimensions.height,
1418
- opacity: 1,
1419
- pointerEvents: 'none',
1420
- zIndex: 9999,
1421
- overflow: 'hidden',
1422
- }}
1423
- collapsable={false}
1424
- >
1425
- <MaskView
1426
- ref={maskViewRef}
1427
- imageUri={maskImageUri}
1428
- points={maskPoints}
1429
- width={maskDimensions.width}
1430
- height={maskDimensions.height}
1431
- />
1432
- </View>
1433
- )}
1434
-
1435
- <Modal visible={isLoading} transparent animationType="fade">
1436
- <View style={styles.loadingOverlay}>
1437
- <Image
1438
- source={require('../src/assets/loadingCamera.gif')}
1439
- style={styles.loadingGif}
1440
- resizeMode="contain"
1441
- />
1442
- </View>
1443
- </Modal>
1444
- </View>
1445
- );
1446
- };
1447
-
1
+ import styles from './ImageCropperStyles';
2
+ import React, { useState, useRef, useEffect } from 'react';
3
+ import { Modal, View, Image, TouchableOpacity, Animated, Text, Platform, SafeAreaView, PixelRatio, StyleSheet, ActivityIndicator, useWindowDimensions } from 'react-native';
4
+ import Svg, { Path, Circle } from 'react-native-svg';
5
+ import CustomCamera from './CustomCamera';
6
+ import * as ImageManipulator from 'expo-image-manipulator';
7
+ import { Ionicons } from '@expo/vector-icons';
8
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
9
+ import { applyMaskToImage, MaskView } from './ImageMaskProcessor';
10
+
11
+ const PRIMARY_GREEN = '#198754';
12
+
13
+ // Max width for crop preview on large screens (tablets) - must match CustomCamera.js
14
+ const CAMERA_PREVIEW_MAX_WIDTH = 500;
15
+
16
+ const ImageCropper = ({ onConfirm, openCameraFirst, initialImage, addheight, rotationLabel }) => {
17
+ const { width: windowWidth } = useWindowDimensions();
18
+ const cameraPreviewWidth = Math.min(windowWidth, CAMERA_PREVIEW_MAX_WIDTH);
19
+
20
+ const [image, setImage] = useState(null);
21
+ const [points, setPoints] = useState([]);
22
+ const [showResult, setShowResult] = useState(false);
23
+ const [showCustomCamera, setShowCustomCamera] = useState(false);
24
+ const viewRef = useRef(null);
25
+ const maskViewRef = useRef(null); // Ref pour la vue de masque (invisible)
26
+ const sourceImageUri = useRef(null); // keep original image URI (full-res) for upload
27
+ const cameraFrameData = useRef(null); // Store green frame coordinates from camera
28
+
29
+ // ✅ RÉFÉRENTIEL UNIQUE : Wrapper commun 9/16 (identique à CustomCamera)
30
+ // Ce wrapper est utilisé dans CustomCamera ET ImageCropper pour garantir pixel-perfect matching
31
+ const commonWrapperRef = useRef(null);
32
+ const commonWrapperLayout = useRef({ x: 0, y: 0, width: 0, height: 0 });
33
+
34
+ // REFACTORISATION : Séparation claire entre dimensions originales et affichage
35
+ // Dimensions réelles de l'image originale (pixels)
36
+ const originalImageDimensions = useRef({ width: 0, height: 0 });
37
+ // Dimensions et position d'affichage à l'écran (pour le calcul des points de crop)
38
+ const displayedImageLayout = useRef({ x: 0, y: 0, width: 0, height: 0 });
39
+ // Conserver imageMeasure pour compatibilité avec le code existant (utilisé pour SVG overlay)
40
+ const imageMeasure = useRef({ x: 0, y: 0, width: 0, height: 0 });
41
+ // ✅ imageDisplayRect : Rectangle réel de l'image affichée (quand resizeMode='contain') à l'intérieur du wrapper commun
42
+ // C'est la zone où l'image est réellement visible (avec letterboxing si nécessaire)
43
+ // Les points de crop DOIVENT rester dans cette zone pour éviter de cropper hors de l'image
44
+ const imageDisplayRect = useRef({ x: 0, y: 0, width: 0, height: 0 });
45
+
46
+ // COMPATIBILITÉ : displayedContentRect reste pour le code existant, mais pointe vers imageDisplayRect
47
+ const displayedContentRect = imageDisplayRect;
48
+
49
+ // RÉFÉRENTIEL UNIQUE : Calculer imageDisplayRect à l'intérieur du wrapper commun
50
+ // - CAMERA: use "cover" mode → image fills wrapper, imageDisplayRect = full wrapper (same as preview)
51
+ // - GALLERY: use "contain" mode imageDisplayRect = letterboxed area
52
+ const updateImageDisplayRect = (wrapperWidth, wrapperHeight) => {
53
+ const iw = originalImageDimensions.current.width;
54
+ const ih = originalImageDimensions.current.height;
55
+
56
+ // ✅ CAMERA IMAGE: Use full wrapper so green frame and white frame show same content
57
+ if (cameraFrameData.current && cameraFrameData.current.greenFrame && wrapperWidth > 0 && wrapperHeight > 0) {
58
+ imageDisplayRect.current = { x: 0, y: 0, width: wrapperWidth, height: wrapperHeight };
59
+ console.log("✅ Image display rect (COVER mode for camera - full wrapper):", imageDisplayRect.current);
60
+ return;
61
+ }
62
+
63
+ console.log("🔄 updateImageDisplayRect called:", {
64
+ originalDimensions: { width: iw, height: ih },
65
+ wrapperDimensions: { width: wrapperWidth, height: wrapperHeight }
66
+ });
67
+
68
+ if (iw > 0 && ih > 0 && wrapperWidth > 0 && wrapperHeight > 0) {
69
+ // Calculer comment l'image s'affiche en "contain" dans le wrapper (gallery)
70
+ const scale = Math.min(wrapperWidth / iw, wrapperHeight / ih);
71
+ const imageDisplayWidth = iw * scale;
72
+ const imageDisplayHeight = ih * scale;
73
+ const offsetX = (wrapperWidth - imageDisplayWidth) / 2;
74
+ const offsetY = (wrapperHeight - imageDisplayHeight) / 2;
75
+
76
+ imageDisplayRect.current = {
77
+ x: offsetX,
78
+ y: offsetY,
79
+ width: imageDisplayWidth,
80
+ height: imageDisplayHeight,
81
+ };
82
+ console.log("✅ Image display rect (contain in wrapper) calculated:", {
83
+ wrapper: { width: wrapperWidth, height: wrapperHeight },
84
+ imageDisplayRect: imageDisplayRect.current,
85
+ scale: scale.toFixed(4)
86
+ });
87
+ return;
88
+ }
89
+
90
+ if (wrapperWidth > 0 && wrapperHeight > 0) {
91
+ imageDisplayRect.current = { x: 0, y: 0, width: wrapperWidth, height: wrapperHeight };
92
+ console.log("⚠️ Using wrapper dimensions as fallback (original dimensions not available yet):", imageDisplayRect.current);
93
+ } else {
94
+ imageDisplayRect.current = { x: 0, y: 0, width: 0, height: 0 };
95
+ console.warn("❌ Cannot calculate imageDisplayRect: missing dimensions");
96
+ }
97
+ };
98
+
99
+ // ✅ COMPATIBILITÉ : Alias pour le code existant
100
+ const updateDisplayedContentRect = updateImageDisplayRect;
101
+
102
+ const selectedPointIndex = useRef(null);
103
+ const lastTap = useRef(null);
104
+
105
+ // ✅ CRITICAL: Guard to prevent double crop box initialization
106
+ // This ensures crop box is initialized only once, especially for camera images
107
+ const hasInitializedCropBox = useRef(false);
108
+ const imageSource = useRef(null); // 'camera' | 'gallery' | null
109
+
110
+ // ✅ FREE DRAG: Store initial touch position and point position for delta-based movement
111
+ const initialTouchPosition = useRef(null); // { x, y } - initial touch position when drag starts
112
+ const initialPointPosition = useRef(null); // { x, y } - initial point position when drag starts
113
+ const lastTouchPosition = useRef(null); // { x, y } - last touch position for incremental delta calculation
114
+
115
+ // ✅ CRITICAL: dragBase stores the VISUAL position (can be overshoot) during drag
116
+ // This ensures smooth continuous drag without "dead zones" at boundaries
117
+ // dragBase is updated with applied position (overshoot) after each movement
118
+ // and is used as base for next delta calculation
119
+ const dragBase = useRef(null); // { x, y } - visual position during drag (can be overshoot)
120
+
121
+ // ✅ NEW APPROACH: touchOffset stores the initial offset between point and touch
122
+ // This eliminates delta accumulation issues and "dead zones"
123
+ // Once set at drag start, it remains constant throughout the drag
124
+ const touchOffset = useRef(null); // { x, y } - offset = pointPosition - touchPosition
125
+
126
+ // ✅ Track if point was clamped in previous frame (to detect transition)
127
+ const wasClampedLastFrame = useRef({ x: false, y: false });
128
+
129
+ // Angle de rotation accumulé (pour éviter les rotations multiples)
130
+ const rotationAngle = useRef(0);
131
+
132
+ // États pour la vue de masque temporaire
133
+ const [maskImageUri, setMaskImageUri] = useState(null);
134
+ const [maskPoints, setMaskPoints] = useState([]);
135
+ const [maskDimensions, setMaskDimensions] = useState({ width: 0, height: 0 });
136
+ const [showMaskView, setShowMaskView] = useState(false);
137
+
138
+ const [isLoading, setIsLoading] = useState(false);
139
+ const [showFullScreenCapture, setShowFullScreenCapture] = useState(false);
140
+ const [isRotating, setIsRotating] = useState(false);
141
+ const rotationInProgressRef = useRef(false); // block duplicate taps immediately
142
+ const lastValidPosition = useRef(null);
143
+ const insets = useSafeAreaInsets();
144
+
145
+ // ✅ NEW ARCH: mobile does NOT export the final crop.
146
+
147
+ // No view-shot / captureRef / bitmap masking on device.
148
+ const enableMask = false;
149
+ const enableRotation = true; // rotation resets crop box so it is re-initialized on rotated image.
150
+
151
+
152
+
153
+
154
+
155
+ useEffect(() => {
156
+ if (openCameraFirst) {
157
+ setShowCustomCamera(true);
158
+ } else if (initialImage) {
159
+ setImage(initialImage);
160
+ sourceImageUri.current = initialImage;
161
+ // CRITICAL: Reset points when loading a new image from gallery
162
+ // This ensures the crop box will be automatically initialized
163
+ setPoints([]);
164
+ rotationAngle.current = 0;
165
+ // Clear camera frame data for gallery images
166
+ cameraFrameData.current = null;
167
+ // ✅ CRITICAL: Reset initialization guard for new image
168
+ hasInitializedCropBox.current = false;
169
+ imageSource.current = null;
170
+ }
171
+ }, [openCameraFirst, initialImage]);
172
+
173
+
174
+ // REFACTORISATION : Stocker uniquement les dimensions originales (pas de calcul théorique)
175
+ // CRITICAL FIX: Single source of truth for image dimensions
176
+
177
+ useEffect(() => {
178
+ if (!image) {
179
+ originalImageDimensions.current = { width: 0, height: 0 };
180
+ hasInitializedCropBox.current = false;
181
+ imageSource.current = null;
182
+ return;
183
+ }
184
+ if (!sourceImageUri.current) {
185
+ sourceImageUri.current = image;
186
+ }
187
+
188
+ // CRITICAL FIX #1: If we have capturedImageSize from camera, use it as SINGLE SOURCE OF TRUTH
189
+ // takePictureAsync returns physical dimensions, while Image.getSize() may return EXIF-oriented dimensions
190
+ // DO NOT call Image.getSize() for camera images - it can return swapped dimensions on Android
191
+ if (cameraFrameData.current && cameraFrameData.current.capturedImageSize) {
192
+ const { width: capturedWidth, height: capturedHeight } = cameraFrameData.current.capturedImageSize;
193
+ originalImageDimensions.current = {
194
+ width: capturedWidth,
195
+ height: capturedHeight,
196
+ };
197
+ imageSource.current = 'camera';
198
+ hasInitializedCropBox.current = false; // Reset guard for new camera image
199
+
200
+ console.log("✅ Using captured image dimensions from takePictureAsync (SINGLE SOURCE OF TRUTH):", {
201
+ width: capturedWidth,
202
+ height: capturedHeight,
203
+ source: 'takePictureAsync',
204
+ note: 'Image.getSize() will NOT be called for camera images'
205
+ });
206
+
207
+ // ✅ CRITICAL: Recalculate imageDisplayRect immediately with camera dimensions
208
+ const wrapper = commonWrapperLayout.current;
209
+ if (wrapper.width > 0 && wrapper.height > 0) {
210
+ updateImageDisplayRect(wrapper.width, wrapper.height);
211
+
212
+ // ✅ CRITICAL FIX #2: Initialize crop box immediately when cameraFrameData is available
213
+ // This ensures camera images are initialized from greenFrame BEFORE any other initialization
214
+ if (cameraFrameData.current && cameraFrameData.current.greenFrame && !hasInitializedCropBox.current) {
215
+ console.log("✅ Initializing crop box from cameraFrameData (immediate in useEffect):", {
216
+ hasGreenFrame: !!cameraFrameData.current.greenFrame,
217
+ wrapper: wrapper,
218
+ originalDimensions: originalImageDimensions.current
219
+ });
220
+ initializeCropBox();
221
+ }
222
+ }
223
+
224
+ return; // CRITICAL: Exit early - DO NOT call Image.getSize()
225
+ }
226
+
227
+ // ✅ FALLBACK: Use Image.getSize() ONLY for gallery images (no cameraFrameData)
228
+ // BUT: Check again right before calling to avoid race condition
229
+ if (cameraFrameData.current && cameraFrameData.current.capturedImageSize) {
230
+ console.log("⚠️ cameraFrameData exists, skipping Image.getSize() call");
231
+ return;
232
+ }
233
+
234
+ // ✅ CRITICAL: Also check imageSource - if it's 'camera', don't call Image.getSize()
235
+ if (imageSource.current === 'camera') {
236
+ console.log("⚠️ imageSource is 'camera', skipping Image.getSize() call");
237
+ return;
238
+ }
239
+
240
+ // ✅ CRITICAL: Set imageSource to 'gallery' ONLY if we're sure it's not a camera image
241
+ // Don't set it yet - we'll set it in the callback after verifying
242
+ hasInitializedCropBox.current = false; // Reset guard for new image
243
+
244
+ Image.getSize(image, (imgWidth, imgHeight) => {
245
+ // CRITICAL SAFETY #1: Check if cameraFrameData appeared while Image.getSize() was resolving
246
+ // This is the PRIMARY check - cameraFrameData takes precedence
247
+ if (cameraFrameData.current && cameraFrameData.current.capturedImageSize) {
248
+ console.warn("⚠️ Image.getSize() resolved but cameraFrameData exists - IGNORING Image.getSize() result to prevent dimension swap");
249
+ console.warn("⚠️ Camera dimensions (correct):", cameraFrameData.current.capturedImageSize);
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 #2: Check imageSource (should be 'camera' if cameraFrameData was set)
255
+ if (imageSource.current === 'camera') {
256
+ console.warn("⚠️ Image.getSize() resolved but imageSource is 'camera' - IGNORING Image.getSize() result");
257
+ console.warn("⚠️ Image.getSize() dimensions (potentially swapped):", { width: imgWidth, height: imgHeight });
258
+ return; // CRITICAL: Exit early - do NOT update dimensions or initialize crop box
259
+ }
260
+
261
+ // ✅ CRITICAL SAFETY #3: Check if crop box was already initialized (from camera)
262
+ if (hasInitializedCropBox.current) {
263
+ console.warn("⚠️ Image.getSize() resolved but crop box already initialized - IGNORING result to prevent double initialization");
264
+ return;
265
+ }
266
+
267
+ // ✅ SAFE: This is a gallery image, proceed with Image.getSize() result
268
+ imageSource.current = 'gallery';
269
+
270
+ originalImageDimensions.current = {
271
+ width: imgWidth,
272
+ height: imgHeight,
273
+ };
274
+
275
+ console.log("✅ Image dimensions from Image.getSize() (gallery image):", {
276
+ width: imgWidth,
277
+ height: imgHeight,
278
+ platform: Platform.OS,
279
+ pixelRatio: PixelRatio.get(),
280
+ uri: image,
281
+ source: 'Image.getSize()'
282
+ });
283
+
284
+ // ✅ RÉFÉRENTIEL UNIQUE : Recalculer imageDisplayRect dans le wrapper commun
285
+ // dès qu'on connaît la taille originale de l'image
286
+ const wrapper = commonWrapperLayout.current;
287
+ if (wrapper.width > 0 && wrapper.height > 0) {
288
+ updateImageDisplayRect(wrapper.width, wrapper.height);
289
+ // ✅ IMPORTANT: pour les images de la galerie (pas de cameraFrameData),
290
+ // initialiser automatiquement le cadre blanc (70% du wrapper) une fois que
291
+ // nous connaissons à la fois le wrapper et les dimensions originales.
292
+ // ✅ CRITICAL: Guard against double initialization
293
+ if (!hasInitializedCropBox.current && points.length === 0 && imageSource.current === 'gallery') {
294
+ initializeCropBox();
295
+ hasInitializedCropBox.current = true;
296
+ }
297
+ }
298
+ }, (error) => {
299
+ console.error("Error getting image size:", error);
300
+ });
301
+ }, [image]);
302
+
303
+
304
+
305
+
306
+ // Le cadre blanc doit être calculé sur le MÊME wrapper que le cadre vert (9/16)
307
+ // Ensuite, on restreint les points pour qu'ils restent dans imageDisplayRect (image visible)
308
+ const initializeCropBox = () => {
309
+ // ✅ CRITICAL FIX #2: Guard against double initialization
310
+ if (hasInitializedCropBox.current) {
311
+ console.log("⚠️ Crop box already initialized, skipping duplicate initialization");
312
+ return;
313
+ }
314
+
315
+ // ✅ CRITICAL: Ensure common wrapper layout is available
316
+ const wrapper = commonWrapperLayout.current;
317
+ if (wrapper.width === 0 || wrapper.height === 0) {
318
+ console.warn("Cannot initialize crop box: common wrapper layout not ready");
319
+ return;
320
+ }
321
+
322
+ // ✅ CRITICAL: Ensure imageDisplayRect is available (zone réelle de l'image dans le wrapper)
323
+ let imageRect = imageDisplayRect.current;
324
+ if (imageRect.width === 0 || imageRect.height === 0) {
325
+ // Recalculer si nécessaire
326
+ if (originalImageDimensions.current.width > 0 && originalImageDimensions.current.height > 0) {
327
+ updateImageDisplayRect(wrapper.width, wrapper.height);
328
+ imageRect = imageDisplayRect.current;
329
+ } else {
330
+ console.warn("Cannot initialize crop box: imageDisplayRect not available (original dimensions missing)");
331
+ return;
332
+ }
333
+ }
334
+
335
+ // ✅ CRITICAL FIX: Calculate crop box as percentage of VISIBLE IMAGE AREA (imageDisplayRect)
336
+ // NOT the wrapper. This ensures the crop box is truly 80% of the image, not 80% of wrapper then clamped.
337
+ // Calculate absolute position of imageDisplayRect within wrapper
338
+ const imageRectX = wrapper.x + imageRect.x;
339
+ const imageRectY = wrapper.y + imageRect.y;
340
+
341
+ // PRIORITY RULE #3: IF image comes from camera → Use EXACT green frame coordinates
342
+ // Image is displayed in "cover" mode (full wrapper), so green frame coords = white frame coords.
343
+ // Store points in WRAPPER-RELATIVE coordinates (greenFrame.x, greenFrame.y) so they match
344
+ // touch events (locationX/locationY are relative to the wrapper). This keeps display→image
345
+ // conversion correct in Confirm.
346
+ if (cameraFrameData.current && cameraFrameData.current.greenFrame && originalImageDimensions.current.width > 0) {
347
+ const greenFrame = cameraFrameData.current.greenFrame;
348
+
349
+ const boxX = greenFrame.x;
350
+ const boxY = greenFrame.y;
351
+ const boxWidth = greenFrame.width;
352
+ const boxHeight = greenFrame.height;
353
+
354
+ // SAFETY: Validate calculated coordinates before creating points
355
+ const isValidCoordinate = (val) => typeof val === 'number' && isFinite(val) && !isNaN(val);
356
+
357
+ if (!isValidCoordinate(boxX) || !isValidCoordinate(boxY) ||
358
+ !isValidCoordinate(boxWidth) || !isValidCoordinate(boxHeight)) {
359
+ console.warn("⚠️ Invalid coordinates calculated for crop box, skipping initialization");
360
+ return;
361
+ }
362
+
363
+ // Points in wrapper-relative coords (same as touch events)
364
+ const newPoints = [
365
+ { x: boxX, y: boxY }, // Top-left
366
+ { x: boxX + boxWidth, y: boxY }, // Top-right
367
+ { x: boxX + boxWidth, y: boxY + boxHeight }, // Bottom-right
368
+ { x: boxX, y: boxY + boxHeight }, // Bottom-left
369
+ ];
370
+
371
+ // ✅ SAFETY: Validate all points before setting
372
+ const validPoints = newPoints.filter(p => isValidCoordinate(p.x) && isValidCoordinate(p.y));
373
+ if (validPoints.length !== newPoints.length) {
374
+ console.warn("⚠️ Some points have invalid coordinates, skipping initialization");
375
+ return;
376
+ }
377
+
378
+ console.log("✅ Initializing crop box for camera image (COVER MODE - exact green frame):", {
379
+ greenFrame: { x: greenFrame.x, y: greenFrame.y, width: greenFrame.width, height: greenFrame.height },
380
+ whiteFrame: { x: boxX.toFixed(2), y: boxY.toFixed(2), width: boxWidth.toFixed(2), height: boxHeight.toFixed(2) },
381
+ note: "Points in wrapper-relative coords - same as touch events and crop_image_size",
382
+ });
383
+
384
+ setPoints(newPoints);
385
+ hasInitializedCropBox.current = true; // ✅ CRITICAL: Mark as initialized
386
+ // ✅ CRITICAL: DO NOT nullify cameraFrameData here - keep it for Image.getSize() callback check
387
+ // It will be cleared when loading a new image
388
+ return;
389
+ }
390
+
391
+ // ✅ PRIORITY RULE #3: DEFAULT logic ONLY for gallery images (NOT camera)
392
+ // If we reach here and imageSource is 'camera', something went wrong
393
+ if (imageSource.current === 'camera') {
394
+ console.warn("⚠️ Camera image but no greenFrame found - this should not happen");
395
+ return;
396
+ }
397
+
398
+ // ✅ DEFAULT: Crop box (70% of VISIBLE IMAGE AREA - centered) - ONLY for gallery images
399
+ const boxWidth = imageRect.width * 0.70; // 70% of visible image width
400
+ const boxHeight = imageRect.height * 0.70; // 70% of visible image height
401
+ const boxX = imageRectX + (imageRect.width - boxWidth) / 2; // Centered in image area
402
+ const boxY = imageRectY + (imageRect.height - boxHeight) / 2; // Centered in image area
403
+
404
+ // SAFETY: Validate calculated coordinates before creating points
405
+ const isValidCoordinate = (val) => typeof val === 'number' && isFinite(val) && !isNaN(val);
406
+
407
+ if (!isValidCoordinate(boxX) || !isValidCoordinate(boxY) ||
408
+ !isValidCoordinate(boxWidth) || !isValidCoordinate(boxHeight)) {
409
+ console.warn("⚠️ Invalid coordinates calculated for default crop box, skipping initialization");
410
+ return;
411
+ }
412
+
413
+ const newPoints = [
414
+ { x: boxX, y: boxY },
415
+ { x: boxX + boxWidth, y: boxY },
416
+ { x: boxX + boxWidth, y: boxY + boxHeight },
417
+ { x: boxX, y: boxY + boxHeight },
418
+ ];
419
+
420
+ // ✅ SAFETY: Validate all points before setting
421
+ const validPoints = newPoints.filter(p => isValidCoordinate(p.x) && isValidCoordinate(p.y));
422
+ if (validPoints.length !== newPoints.length) {
423
+ console.warn("⚠️ Some points have invalid coordinates in default crop box, skipping initialization");
424
+ return;
425
+ }
426
+
427
+ console.log("✅ Initializing crop box (default - 70% of visible image area, gallery only):", {
428
+ wrapper: { width: wrapper.width, height: wrapper.height },
429
+ imageDisplayRect: imageRect,
430
+ boxInImage: { x: boxX, y: boxY, width: boxWidth, height: boxHeight },
431
+ points: newPoints
432
+ });
433
+
434
+ setPoints(newPoints);
435
+ hasInitializedCropBox.current = true; // ✅ CRITICAL: Mark as initialized
436
+ };
437
+
438
+ // ✅ RÉFÉRENTIEL UNIQUE : Callback pour mettre à jour le layout du wrapper commun
439
+ // Ce wrapper a exactement les mêmes dimensions que le wrapper de CustomCamera (9/16, width = screenWidth)
440
+ const onCommonWrapperLayout = (e) => {
441
+ const layout = e.nativeEvent.layout;
442
+ commonWrapperLayout.current = {
443
+ x: layout.x,
444
+ y: layout.y,
445
+ width: layout.width,
446
+ height: layout.height
447
+ };
448
+
449
+ console.log("✅ Common wrapper layout updated:", commonWrapperLayout.current);
450
+
451
+ // Recalculer imageDisplayRect dès que le wrapper est prêt
452
+ if (originalImageDimensions.current.width > 0 && originalImageDimensions.current.height > 0) {
453
+ updateImageDisplayRect(layout.width, layout.height);
454
+
455
+ // ✅ CRITICAL FIX #2: Initialize crop box ONLY if not already initialized
456
+ // For camera images: initialize ONLY from greenFrame (already done when cameraFrameData was set)
457
+ if (!hasInitializedCropBox.current && points.length === 0) {
458
+ // ✅ CRITICAL: Only initialize for gallery images here
459
+ // Camera images should be initialized when cameraFrameData is set, not here
460
+ if (imageSource.current !== 'camera') {
461
+ initializeCropBox();
462
+ }
463
+ }
464
+ }
465
+ };
466
+
467
+ // REFACTORISATION : Mettre à jour les dimensions d'affichage et les dimensions pour SVG
468
+ const onImageLayout = (e) => {
469
+ const layout = e.nativeEvent.layout;
470
+
471
+ // Stocker les dimensions d'affichage réelles (pour conversion de coordonnées)
472
+ displayedImageLayout.current = {
473
+ x: layout.x,
474
+ y: layout.y,
475
+ width: layout.width,
476
+ height: layout.height
477
+ };
478
+
479
+ // Conserver aussi dans imageMeasure pour compatibilité avec SVG overlay
480
+ imageMeasure.current = {
481
+ x: layout.x,
482
+ y: layout.y,
483
+ width: layout.width,
484
+ height: layout.height
485
+ };
486
+
487
+ // ✅ Si l'image vient de la caméra et que les dimensions originales ne sont pas encore définies,
488
+ // les initialiser à partir de cameraFrameData AVANT de calculer le contentRect.
489
+ if (
490
+ originalImageDimensions.current.width === 0 &&
491
+ cameraFrameData.current &&
492
+ cameraFrameData.current.capturedImageSize
493
+ ) {
494
+ const { width, height } = cameraFrameData.current.capturedImageSize;
495
+ originalImageDimensions.current = { width, height };
496
+ console.log("✅ originalImageDimensions initialisées depuis cameraFrameData dans onImageLayout:", {
497
+ width,
498
+ height,
499
+ });
500
+ }
501
+
502
+ // ✅ RÉFÉRENTIEL UNIQUE : Recalculer imageDisplayRect dans le wrapper commun
503
+ // Si le wrapper commun n'est pas encore prêt, on attendra onCommonWrapperLayout
504
+ const wrapper = commonWrapperLayout.current;
505
+ if (wrapper.width > 0 && wrapper.height > 0) {
506
+ updateImageDisplayRect(wrapper.width, wrapper.height);
507
+ }
508
+
509
+ console.log("Displayed image layout updated:", {
510
+ width: layout.width,
511
+ height: layout.height,
512
+ x: layout.x,
513
+ y: layout.y
514
+ });
515
+
516
+ // CRITICAL FIX #2: Do NOT initialize crop box in onImageLayout for camera images
517
+ // Camera images should be initialized ONLY when cameraFrameData is set (in useEffect)
518
+ // Gallery images can be initialized here if not already done
519
+ if (
520
+ wrapper.width > 0 &&
521
+ wrapper.height > 0 &&
522
+ layout.width > 0 &&
523
+ layout.height > 0 &&
524
+ !hasInitializedCropBox.current &&
525
+ points.length === 0 &&
526
+ originalImageDimensions.current.width > 0 &&
527
+ originalImageDimensions.current.height > 0
528
+ ) {
529
+ // ✅ CRITICAL: Only initialize for gallery images here
530
+ // Camera images should be initialized when cameraFrameData is set, not here
531
+ if (imageSource.current !== 'camera') {
532
+ initializeCropBox();
533
+ } else if (cameraFrameData.current && cameraFrameData.current.greenFrame) {
534
+ // ✅ For camera images, initialize ONLY if greenFrame is available
535
+ initializeCropBox();
536
+ }
537
+ }
538
+ };
539
+
540
+ const createPath = () => {
541
+ if (points.length < 1) return '';
542
+ let path = `M ${points[0].x} ${points[0].y} `;
543
+ points.forEach(point => path += `L ${point.x} ${point.y} `);
544
+ return path + 'Z';
545
+ };
546
+
547
+ // ✅ Helper function: Find closest point on a line segment to a tap point
548
+ const findClosestPointOnLine = (tapX, tapY, lineStartX, lineStartY, lineEndX, lineEndY) => {
549
+ const dx = lineEndX - lineStartX;
550
+ const dy = lineEndY - lineStartY;
551
+ const lengthSquared = dx * dx + dy * dy;
552
+
553
+ if (lengthSquared === 0) {
554
+ // Line segment is a point
555
+ return { x: lineStartX, y: lineStartY, distance: Math.sqrt((tapX - lineStartX) ** 2 + (tapY - lineStartY) ** 2) };
556
+ }
557
+
558
+ // Calculate projection parameter t (0 to 1)
559
+ const t = Math.max(0, Math.min(1, ((tapX - lineStartX) * dx + (tapY - lineStartY) * dy) / lengthSquared));
560
+
561
+ // Calculate closest point on line segment
562
+ const closestX = lineStartX + t * dx;
563
+ const closestY = lineStartY + t * dy;
564
+
565
+ // Calculate distance from tap to closest point
566
+ const distance = Math.sqrt((tapX - closestX) ** 2 + (tapY - closestY) ** 2);
567
+
568
+ return { x: closestX, y: closestY, distance, t };
569
+ };
570
+
571
+ // Helper function: Check if tap is near any line segment and find closest point
572
+ const findClosestPointOnFrame = (tapX, tapY, lineTolerance = 30) => {
573
+ if (points.length < 2) return null;
574
+
575
+ let closestPoint = null;
576
+ let minDistance = Infinity;
577
+ let insertIndex = -1;
578
+
579
+ // Check each line segment (closed polygon: last point connects to first)
580
+ for (let i = 0; i < points.length; i++) {
581
+ const start = points[i];
582
+ const end = points[(i + 1) % points.length];
583
+
584
+ const result = findClosestPointOnLine(tapX, tapY, start.x, start.y, end.x, end.y);
585
+
586
+ if (result.distance < minDistance && result.distance < lineTolerance) {
587
+ minDistance = result.distance;
588
+ closestPoint = { x: result.x, y: result.y };
589
+ // Insert after the start point of this segment
590
+ insertIndex = i + 1;
591
+ }
592
+ }
593
+
594
+ return closestPoint ? { point: closestPoint, insertIndex } : null;
595
+ };
596
+
597
+ const handleTap = (e) => {
598
+ if (!image || showResult) return;
599
+ const now = Date.now();
600
+ const { locationX: tapX, locationY: tapY } = e.nativeEvent;
601
+
602
+ // RÉFÉRENTIEL UNIQUE : Utiliser imageDisplayRect (zone réelle de l'image dans le wrapper)
603
+ // Les coordonnées du tap sont relatives au wrapper commun
604
+ let imageRect = imageDisplayRect.current;
605
+ const wrapper = commonWrapperLayout.current;
606
+
607
+ // Recalculate if not available
608
+ if (imageRect.width === 0 || imageRect.height === 0) {
609
+ if (wrapper.width > 0 && wrapper.height > 0 && originalImageDimensions.current.width > 0) {
610
+ updateImageDisplayRect(wrapper.width, wrapper.height);
611
+ imageRect = imageDisplayRect.current;
612
+ }
613
+ // If still not available, use wrapper as fallback
614
+ if (imageRect.width === 0 || imageRect.height === 0) {
615
+ if (wrapper.width > 0 && wrapper.height > 0) {
616
+ imageRect = {
617
+ x: wrapper.x,
618
+ y: wrapper.y,
619
+ width: wrapper.width,
620
+ height: wrapper.height
621
+ };
622
+ } else {
623
+ console.warn("⚠️ Cannot handle tap: wrapper or imageDisplayRect not available");
624
+ return;
625
+ }
626
+ }
627
+ }
628
+
629
+ // ✅ Clamp to real displayed image content (imageDisplayRect dans le wrapper)
630
+ // Les coordonnées tapX/tapY sont relatives au wrapper commun
631
+ const imageRectX = wrapper.x + imageRect.x;
632
+ const imageRectY = wrapper.y + imageRect.y;
633
+ const { x: cx, y: cy, width: cw, height: ch } = {
634
+ x: imageRectX,
635
+ y: imageRectY,
636
+ width: imageRect.width,
637
+ height: imageRect.height
638
+ };
639
+ // Larger select radius for easier point selection (especially on touch screens)
640
+ const selectRadius = 50; // Increased from 28 to 50 for better UX
641
+
642
+ // ✅ CRITICAL: Check for existing point selection FIRST (using raw tap coordinates)
643
+ // Don't clamp tapX/Y for point selection - points can be anywhere in wrapper now
644
+ const index = points.findIndex(p => Math.abs(p.x - tapX) < selectRadius && Math.abs(p.y - tapY) < selectRadius);
645
+
646
+ if (index !== -1) {
647
+ // Point found - select it for dragging
648
+ selectedPointIndex.current = index;
649
+
650
+ // Store initial positions
651
+ initialTouchPosition.current = { x: tapX, y: tapY };
652
+ lastTouchPosition.current = { x: tapX, y: tapY };
653
+ initialPointPosition.current = { ...points[index] };
654
+ lastValidPosition.current = { ...points[index] };
655
+
656
+ // Calculate offset between point and touch at drag start
657
+ touchOffset.current = {
658
+ x: points[index].x - tapX,
659
+ y: points[index].y - tapY
660
+ };
661
+
662
+ console.log("🎯 DRAG START - Offset calculated:", {
663
+ pointX: points[index].x.toFixed(2),
664
+ pointY: points[index].y.toFixed(2),
665
+ touchX: tapX.toFixed(2),
666
+ touchY: tapY.toFixed(2),
667
+ offsetX: touchOffset.current.x.toFixed(2),
668
+ offsetY: touchOffset.current.y.toFixed(2)
669
+ });
670
+
671
+ // Disable parent ScrollView scrolling when dragging
672
+ try {
673
+ const findScrollView = (node) => {
674
+ if (!node) return null;
675
+ if (node._component && node._component.setNativeProps) {
676
+ node._component.setNativeProps({ scrollEnabled: false });
677
+ }
678
+ return findScrollView(node._owner || node._parent);
679
+ };
680
+ } catch (e) {
681
+ // Ignore errors
682
+ }
683
+ } else {
684
+ // No point found - check if double-tap on a line to create new point
685
+ const isDoubleTap = lastTap.current && now - lastTap.current < 300;
686
+
687
+ if (isDoubleTap && points.length >= 2) {
688
+ // Find closest point on frame lines
689
+ const lineResult = findClosestPointOnFrame(tapX, tapY, 30); // 30px tolerance
690
+
691
+ if (lineResult) {
692
+ const { point, insertIndex } = lineResult;
693
+
694
+ // Check if a point already exists very close to this position
695
+ const exists = points.some(p => Math.abs(p.x - point.x) < selectRadius && Math.abs(p.y - point.y) < selectRadius);
696
+
697
+ if (!exists) {
698
+ // Insert new point at the correct position in the polygon
699
+ const newPoints = [...points];
700
+ newPoints.splice(insertIndex, 0, point);
701
+ setPoints(newPoints);
702
+
703
+ console.log("✅ New point created on frame line:", {
704
+ tap: { x: tapX.toFixed(2), y: tapY.toFixed(2) },
705
+ newPoint: { x: point.x.toFixed(2), y: point.y.toFixed(2) },
706
+ insertIndex,
707
+ totalPoints: newPoints.length
708
+ });
709
+
710
+ lastTap.current = null; // Reset to prevent triple-tap
711
+ return;
712
+ }
713
+ }
714
+ }
715
+ }
716
+
717
+ lastTap.current = now;
718
+ };
719
+
720
+ const handleMove = (e) => {
721
+ if (showResult || selectedPointIndex.current === null) return;
722
+
723
+ // ✅ FREE DRAG: Use delta-based movement for smooth, unconstrained dragging
724
+
725
+ const nativeEvent = e.nativeEvent;
726
+ const currentX = nativeEvent.locationX;
727
+ const currentY = nativeEvent.locationY;
728
+
729
+ // ✅ Validate coordinates
730
+ if (currentX === undefined || currentY === undefined || isNaN(currentX) || isNaN(currentY)) {
731
+ console.warn("⚠️ Cannot get touch coordinates", {
732
+ locationX: nativeEvent.locationX,
733
+ locationY: nativeEvent.locationY
734
+ });
735
+ return;
736
+ }
737
+
738
+ // This is more reliable when ScrollView affects coordinate updates
739
+ let deltaX, deltaY;
740
+ if (lastTouchPosition.current) {
741
+ // Calculate incremental delta from last touch position
742
+ deltaX = currentX - lastTouchPosition.current.x;
743
+ deltaY = currentY - lastTouchPosition.current.y;
744
+ } else if (initialTouchPosition.current) {
745
+ // Fallback to absolute delta if lastTouchPosition not set
746
+ deltaX = currentX - initialTouchPosition.current.x;
747
+ deltaY = currentY - initialTouchPosition.current.y;
748
+ } else {
749
+ console.warn("⚠️ No touch position reference available");
750
+ return;
751
+ }
752
+
753
+ // Les coordonnées de mouvement sont relatives au wrapper commun
754
+ let imageRect = imageDisplayRect.current;
755
+ const wrapper = commonWrapperLayout.current;
756
+
757
+ // Recalculate if not available
758
+ if (imageRect.width === 0 || imageRect.height === 0) {
759
+ if (wrapper.width > 0 && wrapper.height > 0 && originalImageDimensions.current.width > 0) {
760
+ updateImageDisplayRect(wrapper.width, wrapper.height);
761
+ imageRect = imageDisplayRect.current;
762
+ }
763
+ // If still not available, use wrapper as fallback
764
+ if (imageRect.width === 0 || imageRect.height === 0) {
765
+ if (wrapper.width > 0 && wrapper.height > 0) {
766
+ imageRect = {
767
+ x: wrapper.x,
768
+ y: wrapper.y,
769
+ width: wrapper.width,
770
+ height: wrapper.height
771
+ };
772
+ } else {
773
+ console.warn("⚠️ Cannot move point: wrapper or imageDisplayRect not available");
774
+ return;
775
+ }
776
+ }
777
+ }
778
+
779
+ // ✅ CRITICAL: Calculate absolute bounds of imageDisplayRect within the wrapper
780
+ // imageRect is relative to wrapper, so we need to add wrapper offset
781
+ const imageRectX = wrapper.x + imageRect.x;
782
+ const imageRectY = wrapper.y + imageRect.y;
783
+ const contentRect = {
784
+ x: imageRectX,
785
+ y: imageRectY,
786
+ width: imageRect.width,
787
+ height: imageRect.height
788
+ };
789
+
790
+ // FREE DRAG: Ensure initial positions are set
791
+ if (!initialPointPosition.current) {
792
+ const currentPoint = points[selectedPointIndex.current];
793
+ if (currentPoint && typeof currentPoint.x === 'number' && typeof currentPoint.y === 'number') {
794
+ initialPointPosition.current = { ...currentPoint };
795
+ } else {
796
+ console.warn("⚠️ No point found for selected index or invalid point data");
797
+ return;
798
+ }
799
+ }
800
+
801
+ // ✅ NEW APPROACH: Use touchOffset to map touch position directly to point position
802
+ // This eliminates delta accumulation and "dead zone" issues completely
803
+ if (!touchOffset.current) {
804
+ console.warn("⚠️ touchOffset not initialized, cannot move point");
805
+ return;
806
+ }
807
+
808
+ // ✅ DIRECT MAPPING: newPosition = touchPosition + offset
809
+ // No delta accumulation, no zone morte
810
+ const newX = currentX + touchOffset.current.x;
811
+ const newY = currentY + touchOffset.current.y;
812
+
813
+ // SEPARATE DRAG BOUNDS vs CROP BOUNDS
814
+ const { x: cx, y: cy, width: cw, height: ch } = contentRect;
815
+
816
+ // ✅ STRICT BOUNDS: For final crop safety (imageDisplayRect)
817
+ const strictMinX = cx;
818
+ const strictMaxX = cx + cw;
819
+ const strictMinY = cy;
820
+ const strictMaxY = cy + ch;
821
+
822
+ // DRAG BOUNDS: Allow movement ANYWHERE in wrapper during drag
823
+ // Points can move freely across the entire screen for maximum flexibility
824
+ // They will be clamped to imageDisplayRect only on release for safe cropping
825
+ const wrapperRect = wrapper;
826
+ const overshootMinX = wrapperRect.x;
827
+ const overshootMaxX = wrapperRect.x + wrapperRect.width;
828
+ const overshootMinY = wrapperRect.y;
829
+ const overshootMaxY = wrapperRect.y + wrapperRect.height;
830
+
831
+ // DRAG BOUNDS: Clamp ONLY to overshootBounds during drag (NOT strictBounds)
832
+ const dragX = Math.max(overshootMinX, Math.min(newX, overshootMaxX));
833
+ const dragY = Math.max(overshootMinY, Math.min(newY, overshootMaxY));
834
+
835
+ // UPDATE POINT: Use drag bounds (overshoot) - allows visual freedom
836
+ const updatedPoint = { x: dragX, y: dragY };
837
+
838
+ // CRITICAL: Detect if point is AT overshoot boundary (not just clamped)
839
+ // Check if point is exactly at overshootMin/Max (within 1px tolerance)
840
+ const isAtOvershootMinX = Math.abs(dragX - overshootMinX) < 1;
841
+ const isAtOvershootMaxX = Math.abs(dragX - overshootMaxX) < 1;
842
+ const isAtOvershootMinY = Math.abs(dragY - overshootMinY) < 1;
843
+ const isAtOvershootMaxY = Math.abs(dragY - overshootMaxY) < 1;
844
+
845
+ const isAtBoundaryX = isAtOvershootMinX || isAtOvershootMaxX;
846
+ const isAtBoundaryY = isAtOvershootMinY || isAtOvershootMaxY;
847
+
848
+ // Only recalculate offset when FIRST hitting boundary (transition free boundary)
849
+ const justHitBoundaryX = isAtBoundaryX && !wasClampedLastFrame.current.x;
850
+ const justHitBoundaryY = isAtBoundaryY && !wasClampedLastFrame.current.y;
851
+
852
+ if (justHitBoundaryX || justHitBoundaryY) {
853
+ // Point JUST hit overshoot boundary - recalculate offset once
854
+ const newOffsetX = justHitBoundaryX ? (dragX - currentX) : touchOffset.current.x;
855
+ const newOffsetY = justHitBoundaryY ? (dragY - currentY) : touchOffset.current.y;
856
+
857
+ touchOffset.current = {
858
+ x: newOffsetX,
859
+ y: newOffsetY
860
+ };
861
+
862
+ console.log(" OFFSET RECALCULATED (hit boundary):", {
863
+ axis: justHitBoundaryX ? 'X' : 'Y',
864
+ touchY: currentY.toFixed(2),
865
+ dragY: dragY.toFixed(2),
866
+ newOffsetY: touchOffset.current.y.toFixed(2),
867
+ note: "First contact with boundary - offset locked"
868
+ });
869
+ }
870
+
871
+ // Update boundary state for next frame
872
+ wasClampedLastFrame.current = { x: isAtBoundaryX, y: isAtBoundaryY };
873
+
874
+ // ✅ DEBUG: Log when in overshoot zone (only when not at boundary)
875
+ const isInOvershootY = dragY < strictMinY || dragY > strictMaxY;
876
+ if (isInOvershootY && !isAtBoundaryY) {
877
+ console.log("🎯 IN OVERSHOOT ZONE:", {
878
+ touchY: currentY.toFixed(2),
879
+ appliedY: dragY.toFixed(2),
880
+ overshootRange: `${overshootMinY.toFixed(2)} - ${overshootMaxY.toFixed(2)}`,
881
+ strictRange: `${strictMinY.toFixed(2)} - ${strictMaxY.toFixed(2)}`
882
+ });
883
+ }
884
+
885
+ // ✅ Update lastValidPosition ONLY if point is within strictBounds
886
+ const isStrictlyValid =
887
+ dragX >= strictMinX && dragX <= strictMaxX &&
888
+ dragY >= strictMinY && dragY <= strictMaxY;
889
+
890
+ if (isStrictlyValid) {
891
+ lastValidPosition.current = updatedPoint;
892
+ }
893
+
894
+ // ✅ Update lastTouchPosition for next frame (simple tracking)
895
+ lastTouchPosition.current = { x: currentX, y: currentY };
896
+
897
+ // ✅ DEBUG: Log the point update before setPoints
898
+ console.log("📍 UPDATING POINT:", {
899
+ index: selectedPointIndex.current,
900
+ newX: updatedPoint.x.toFixed(2),
901
+ newY: updatedPoint.y.toFixed(2),
902
+ touchX: currentX.toFixed(2),
903
+ touchY: currentY.toFixed(2),
904
+ offsetX: touchOffset.current.x.toFixed(2),
905
+ offsetY: touchOffset.current.y.toFixed(2)
906
+ });
907
+
908
+ setPoints(prev => {
909
+ // SAFETY: Ensure prev is a valid array
910
+ if (!Array.isArray(prev) || prev.length === 0) {
911
+ return prev;
912
+ }
913
+
914
+ const pointIndex = selectedPointIndex.current;
915
+ // ✅ SAFETY: Validate pointIndex
916
+ if (pointIndex === null || pointIndex === undefined || pointIndex < 0 || pointIndex >= prev.length) {
917
+ return prev;
918
+ }
919
+
920
+ // ✅ SAFETY: Filter out any invalid points and update the selected one
921
+ const newPoints = prev.map((p, i) => {
922
+ if (i === pointIndex) {
923
+ return updatedPoint;
924
+ }
925
+ // SAFETY: Ensure existing points are valid
926
+ if (p && typeof p.x === 'number' && typeof p.y === 'number') {
927
+ return p;
928
+ }
929
+ // If point is invalid, return a default point (shouldn't happen, but safety first)
930
+ return { x: 0, y: 0 };
931
+ });
932
+
933
+ // ✅ DEBUG: Log the state update
934
+ console.log("✅ STATE UPDATED:", {
935
+ index: pointIndex,
936
+ oldY: prev[pointIndex]?.y.toFixed(2),
937
+ newY: newPoints[pointIndex]?.y.toFixed(2),
938
+ changed: Math.abs(prev[pointIndex]?.y - newPoints[pointIndex]?.y) > 0.01
939
+ });
940
+
941
+ return newPoints;
942
+ });
943
+ };
944
+
945
+ const handleRelease = () => {
946
+ const wasDragging = selectedPointIndex.current !== null;
947
+
948
+ // CRITICAL: Reset drag state when drag ends
949
+ touchOffset.current = null;
950
+ wasClampedLastFrame.current = { x: false, y: false };
951
+
952
+ // ✅ VISUAL OVERSHOOT: Clamp points back to imageDisplayRect when drag ends
953
+ // This ensures final crop is always within valid image bounds
954
+ if (wasDragging && selectedPointIndex.current !== null) {
955
+ const wrapper = commonWrapperLayout.current;
956
+ let imageRect = imageDisplayRect.current;
957
+
958
+ // Recalculate imageDisplayRect if needed
959
+ if (imageRect.width === 0 || imageRect.height === 0) {
960
+ if (wrapper.width > 0 && wrapper.height > 0 && originalImageDimensions.current.width > 0) {
961
+ updateImageDisplayRect(wrapper.width, wrapper.height);
962
+ imageRect = imageDisplayRect.current;
963
+ }
964
+ }
965
+
966
+ if (imageRect.width > 0 && imageRect.height > 0) {
967
+ const imageRectX = wrapper.x + imageRect.x;
968
+ const imageRectY = wrapper.y + imageRect.y;
969
+ const imageRectMaxX = imageRectX + imageRect.width;
970
+ const imageRectMaxY = imageRectY + imageRect.height;
971
+
972
+ // Clamp the dragged point back to strict image bounds
973
+ setPoints(prev => {
974
+ // SAFETY: Ensure prev is a valid array
975
+ if (!Array.isArray(prev) || prev.length === 0) {
976
+ return prev;
977
+ }
978
+
979
+ const pointIndex = selectedPointIndex.current;
980
+ // SAFETY: Validate pointIndex and ensure point exists
981
+ if (pointIndex === null || pointIndex === undefined || pointIndex < 0 || pointIndex >= prev.length) {
982
+ return prev;
983
+ }
984
+
985
+ const point = prev[pointIndex];
986
+ // SAFETY: Ensure point exists and has valid x/y properties
987
+ if (!point || typeof point.x !== 'number' || typeof point.y !== 'number') {
988
+ return prev;
989
+ }
990
+
991
+ const clampedPoint = {
992
+ x: Math.max(imageRectX, Math.min(point.x, imageRectMaxX)),
993
+ y: Math.max(imageRectY, Math.min(point.y, imageRectMaxY))
994
+ };
995
+
996
+ // Only update if point was outside bounds
997
+ if (point.x !== clampedPoint.x || point.y !== clampedPoint.y) {
998
+ console.log("🔒 Clamping point back to image bounds on release:", {
999
+ before: { x: point.x.toFixed(2), y: point.y.toFixed(2) },
1000
+ after: { x: clampedPoint.x.toFixed(2), y: clampedPoint.y.toFixed(2) },
1001
+ bounds: { minX: imageRectX.toFixed(2), maxX: imageRectMaxX.toFixed(2), minY: imageRectY.toFixed(2), maxY: imageRectMaxY.toFixed(2) }
1002
+ });
1003
+
1004
+ return prev.map((p, i) => i === pointIndex ? clampedPoint : p);
1005
+ }
1006
+
1007
+ return prev;
1008
+ });
1009
+ }
1010
+ }
1011
+
1012
+ // ✅ FREE DRAG: Clear initial positions when drag ends
1013
+ initialTouchPosition.current = null;
1014
+ initialPointPosition.current = null;
1015
+ lastValidPosition.current = null;
1016
+ selectedPointIndex.current = null;
1017
+
1018
+ // CRITICAL: Re-enable parent ScrollView scrolling when drag ends
1019
+ if (wasDragging) {
1020
+ try {
1021
+ // Re-enable scrolling after a short delay to avoid conflicts
1022
+ setTimeout(() => {
1023
+ // ScrollView will be re-enabled automatically when responder is released
1024
+ }, 100);
1025
+ } catch (e) {
1026
+ // Ignore errors
1027
+ }
1028
+ }
1029
+ };
1030
+
1031
+ const handleReset = () => {
1032
+ // setPoints([]);
1033
+ hasInitializedCropBox.current = false; // CRITICAL: Reset guard to allow reinitialization
1034
+ initializeCropBox();
1035
+ };
1036
+
1037
+ // REFACTORISATION : Stocker l'angle de rotation au lieu de modifier l'image immédiatement
1038
+ // La rotation sera appliquée uniquement lors du crop final pour éviter les interpolations multiples
1039
+ const rotatePreviewImage = async (degrees) => {
1040
+ if (!image) return;
1041
+ if (rotationInProgressRef.current) return; // block duplicate taps immediately (no re-render delay)
1042
+ rotationInProgressRef.current = true;
1043
+ setIsRotating(true);
1044
+
1045
+ try {
1046
+ rotationAngle.current = (rotationAngle.current + degrees) % 360;
1047
+
1048
+ // Use JPEG for preview rotation (faster than PNG for large images; quality 0.92 is fine for preview)
1049
+ const rotated = await ImageManipulator.manipulateAsync(
1050
+ image,
1051
+ [{ rotate: degrees }],
1052
+ {
1053
+ compress: 0.92,
1054
+ format: ImageManipulator.SaveFormat.JPEG,
1055
+ }
1056
+ );
1057
+
1058
+ // ✅ Send rotated image to backend: use rotated URI and dimensions so crop bbox matches
1059
+ sourceImageUri.current = rotated.uri;
1060
+ originalImageDimensions.current = {
1061
+ width: rotated.width,
1062
+ height: rotated.height,
1063
+ };
1064
+ cameraFrameData.current = null; // rotated image is no longer "camera preview" frame
1065
+ imageSource.current = 'gallery'; // so layout callbacks run initializeCropBox() and show the white box
1066
+
1067
+ setPoints([]);
1068
+ hasInitializedCropBox.current = false;
1069
+ setImage(rotated.uri);
1070
+ console.log("Rotation applied:", degrees, "degrees; accumulated:", rotationAngle.current);
1071
+ } catch (error) {
1072
+ console.error("Error rotating image:", error);
1073
+ alert("Error rotating image");
1074
+ } finally {
1075
+ rotationInProgressRef.current = false;
1076
+ setIsRotating(false);
1077
+ }
1078
+ };
1079
+
1080
+ // Helper function to wait for multiple render cycles (works on iOS)
1081
+ const waitForRender = (cycles = 5) => {
1082
+ return new Promise((resolve) => {
1083
+ let count = 0;
1084
+ const tick = () => {
1085
+ count++;
1086
+ if (count >= cycles) {
1087
+ resolve();
1088
+ } else {
1089
+ setImmediate(tick);
1090
+ }
1091
+ };
1092
+ setImmediate(tick);
1093
+ });
1094
+ };
1095
+
1096
+
1097
+ return (
1098
+ <View style={styles.container}>
1099
+
1100
+ {showCustomCamera ? (
1101
+ <CustomCamera
1102
+ onPhotoCaptured={(uri, frameData) => {
1103
+ // ✅ Reset refs for new image so second (and later) photos don't use first image's layout (fixes white screen on some devices)
1104
+ originalImageDimensions.current = { width: 0, height: 0 };
1105
+ imageDisplayRect.current = { x: 0, y: 0, width: 0, height: 0 };
1106
+ displayedImageLayout.current = { x: 0, y: 0, width: 0, height: 0 };
1107
+ imageMeasure.current = { x: 0, y: 0, width: 0, height: 0 };
1108
+ sourceImageUri.current = uri;
1109
+
1110
+ // ✅ CRITICAL FIX: Store green frame coordinates for coordinate conversion
1111
+ if (frameData && frameData.greenFrame) {
1112
+ cameraFrameData.current = {
1113
+ greenFrame: frameData.greenFrame,
1114
+ capturedImageSize: frameData.capturedImageSize
1115
+ };
1116
+ // ✅ CRITICAL: Set imageSource to 'camera' IMMEDIATELY to prevent Image.getSize() from being called
1117
+ imageSource.current = 'camera';
1118
+ hasInitializedCropBox.current = false; // Reset guard for new camera image
1119
+ console.log("✅ Camera frame data received:", cameraFrameData.current);
1120
+ }
1121
+ setImage(uri);
1122
+ setShowCustomCamera(false);
1123
+ // ✅ CORRECTION : Réinitialiser les points et l'angle de rotation quand une nouvelle photo est capturée
1124
+ setPoints([]);
1125
+ rotationAngle.current = 0;
1126
+ // ✅ CRITICAL: initializeCropBox will be called automatically when image layout is ready
1127
+ // The green frame coordinates are stored in cameraFrameData.current and will be used
1128
+ }}
1129
+ onCancel={() => setShowCustomCamera(false)}
1130
+ />
1131
+ ) : (
1132
+ <>
1133
+ {image && (
1134
+ <View
1135
+ style={[
1136
+ styles.commonWrapper,
1137
+ { width: cameraPreviewWidth },
1138
+ ]}
1139
+ ref={commonWrapperRef}
1140
+ onLayout={onCommonWrapperLayout}
1141
+ >
1142
+ <View
1143
+ ref={viewRef}
1144
+ collapsable={false}
1145
+ style={StyleSheet.absoluteFill}
1146
+ onStartShouldSetResponder={() => true}
1147
+ onMoveShouldSetResponder={(evt, gestureState) => {
1148
+ // ✅ CRITICAL: Always capture movement when a point is selected
1149
+ // This ensures vertical movement is captured correctly
1150
+ if (selectedPointIndex.current !== null) {
1151
+ return true;
1152
+ }
1153
+ // CRITICAL: Capture ANY movement immediately (even 0px) to prevent ScrollView interception
1154
+ // This is especially important for vertical movement which ScrollView tries to intercept
1155
+ // We return true for ANY movement to ensure we capture it before ScrollView
1156
+ const hasMovement = Math.abs(gestureState.dx) > 0 || Math.abs(gestureState.dy) > 0;
1157
+ if (hasMovement && Math.abs(gestureState.dy) > 5) {
1158
+ console.log("🔄 Vertical movement detected in responder:", {
1159
+ dx: gestureState.dx.toFixed(2),
1160
+ dy: gestureState.dy.toFixed(2),
1161
+ selectedPoint: selectedPointIndex.current
1162
+ });
1163
+ }
1164
+ return true;
1165
+ }}
1166
+ onResponderGrant={(e) => {
1167
+ // ✅ CRITICAL: Grant responder immediately to prevent ScrollView from intercepting
1168
+ // This ensures we capture all movement, especially vertical
1169
+ // Handle tap to select point if needed
1170
+ if (selectedPointIndex.current === null) {
1171
+ handleTap(e);
1172
+ }
1173
+ }}
1174
+ onResponderStart={handleTap}
1175
+ onResponderMove={(e) => {
1176
+ // ✅ CRITICAL: Always handle move events to ensure smooth movement in all directions
1177
+ // This is called for every move event, ensuring vertical movement is captured
1178
+ // handleMove now uses incremental delta calculation which is more reliable
1179
+ handleMove(e);
1180
+ }}
1181
+ onResponderRelease={handleRelease}
1182
+ onResponderTerminationRequest={() => {
1183
+ // CRITICAL: Never allow termination when dragging a point
1184
+ // This prevents ScrollView from stealing the responder during vertical movement
1185
+ return selectedPointIndex.current === null;
1186
+ }}
1187
+ // ✅ CRITICAL: Prevent parent ScrollView from intercepting touches
1188
+ // Capture responder BEFORE parent ScrollView can intercept
1189
+ onStartShouldSetResponderCapture={() => {
1190
+ // Always capture start events
1191
+ return true;
1192
+ }}
1193
+ onMoveShouldSetResponderCapture={(evt, gestureState) => {
1194
+ // ✅ CRITICAL: Always capture movement events before parent ScrollView
1195
+ // This is essential for vertical movement which ScrollView tries to intercept
1196
+ // Especially important when a point is selected or when there's any movement
1197
+ if (selectedPointIndex.current !== null) {
1198
+ return true;
1199
+ }
1200
+ // ✅ CRITICAL: Capture movement BEFORE ScrollView can intercept
1201
+ // This ensures we get vertical movement even if ScrollView tries to steal it
1202
+ const hasMovement = Math.abs(gestureState.dx) > 0 || Math.abs(gestureState.dy) > 0;
1203
+ return hasMovement;
1204
+ }}
1205
+ >
1206
+ <Image
1207
+ key={image}
1208
+ source={{ uri: image }}
1209
+ style={styles.image}
1210
+ resizeMode={cameraFrameData.current?.greenFrame ? 'cover' : 'contain'}
1211
+ onLayout={onImageLayout}
1212
+ />
1213
+ {/* RÉFÉRENTIEL UNIQUE : SVG overlay utilise les dimensions du wrapper commun */}
1214
+ {/* IMPORTANT: prevent SVG overlay from stealing touch events so dragging works reliably */}
1215
+ <Svg style={styles.overlay} pointerEvents="none">
1216
+ {(() => {
1217
+ // Use wrapper dimensions for SVG path (wrapper coordinates)
1218
+ const wrapperWidth = commonWrapperLayout.current.width || cameraPreviewWidth;
1219
+ const wrapperHeight = commonWrapperLayout.current.height || (cameraPreviewWidth * 16 / 9);
1220
+ return (
1221
+ <>
1222
+ <Path
1223
+ d={`M 0 0 H ${wrapperWidth} V ${wrapperHeight} H 0 Z ${createPath()}`}
1224
+ fill={showResult ? 'white' : 'rgba(0, 0, 0, 0.8)'}
1225
+ fillRule="evenodd"
1226
+ />
1227
+ {!showResult && points.length > 0 && (
1228
+ <Path d={createPath()} fill="transparent" stroke="white" strokeWidth={2} />
1229
+ )}
1230
+ {!showResult && points.map((point, index) => (
1231
+ <Circle key={index} cx={point.x} cy={point.y} r={10} fill="white" />
1232
+ ))}
1233
+ </>
1234
+ );
1235
+ })()}
1236
+ </Svg>
1237
+ </View>
1238
+ {isRotating && (
1239
+ <View style={{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'center', alignItems: 'center' }}>
1240
+ <ActivityIndicator size="large" color={PRIMARY_GREEN} />
1241
+ <Text style={{ color: PRIMARY_GREEN, marginTop: 8, fontSize: 14 }}>{rotationLabel ?? 'Rotation...'}</Text>
1242
+ </View>
1243
+ )}
1244
+ </View>
1245
+ )}
1246
+
1247
+ {/* Buttons positioned BELOW the image, not overlapping */}
1248
+ {!showResult && image && (
1249
+ <View style={[styles.buttonContainerBelow, { paddingBottom: Math.max(insets.bottom, 16) }]}>
1250
+ {Platform.OS === 'android' && (
1251
+ <TouchableOpacity
1252
+ style={[styles.rotationButton, isRotating && { opacity: 0.7 }]}
1253
+ onPress={() => enableRotation && rotatePreviewImage(90)}
1254
+ disabled={isRotating}
1255
+ >
1256
+ {isRotating ? (
1257
+ <ActivityIndicator size="small" color="white" />
1258
+ ) : (
1259
+ <Ionicons name="sync" size={24} color="white" />
1260
+ )}
1261
+ </TouchableOpacity>
1262
+ )}
1263
+
1264
+ <TouchableOpacity style={styles.button} onPress={handleReset}>
1265
+ <Text style={styles.buttonText}>Reset</Text>
1266
+ </TouchableOpacity>
1267
+
1268
+ <TouchableOpacity
1269
+ style={styles.button}
1270
+ onPress={async () => {
1271
+ setIsLoading(true);
1272
+ try {
1273
+ console.log("=== Starting pixel-perfect metadata export (no bitmap crop on mobile) ===");
1274
+
1275
+ let actualImageWidth = originalImageDimensions.current.width;
1276
+ let actualImageHeight = originalImageDimensions.current.height;
1277
+
1278
+ // CRITICAL: Camera JPEGs often have EXIF 6 (90° CW). The Image component displays
1279
+ // the EXIF-corrected view (3120×4160 portrait) but takePictureAsync returns raw (4160×3120).
1280
+ // Use swapped dimensions for coordinate conversion so bbox matches what the user sees.
1281
+ const isCoverMode = !!(cameraFrameData.current && cameraFrameData.current.greenFrame);
1282
+ const captured = cameraFrameData.current?.capturedImageSize;
1283
+ if (isCoverMode && captured && captured.width > captured.height) {
1284
+ actualImageWidth = captured.height;
1285
+ actualImageHeight = captured.width;
1286
+ console.log("✅ Using EXIF-swapped dimensions for bbox (raw was landscape, display is portrait):", {
1287
+ raw: { w: captured.width, h: captured.height },
1288
+ display: { w: actualImageWidth, h: actualImageHeight },
1289
+ });
1290
+ }
1291
+
1292
+ if (actualImageWidth === 0 || actualImageHeight === 0) {
1293
+ throw new Error("Original image dimensions not available. Please wait for image to load.");
1294
+ }
1295
+
1296
+ const layout = displayedImageLayout.current;
1297
+ if (layout.width > 0 && layout.height > 0) {
1298
+ updateDisplayedContentRect(layout.width, layout.height);
1299
+ }
1300
+
1301
+ let contentRect = displayedContentRect.current;
1302
+ let displayedWidth = contentRect.width;
1303
+ let displayedHeight = contentRect.height;
1304
+
1305
+ if (displayedWidth === 0 || displayedHeight === 0) {
1306
+ if (layout.width > 0 && layout.height > 0) {
1307
+ contentRect = {
1308
+ x: layout.x,
1309
+ y: layout.y,
1310
+ width: layout.width,
1311
+ height: layout.height
1312
+ };
1313
+ displayedWidth = contentRect.width;
1314
+ displayedHeight = contentRect.height;
1315
+ displayedContentRect.current = contentRect;
1316
+ } else {
1317
+ throw new Error("Displayed image dimensions not available.");
1318
+ }
1319
+ }
1320
+
1321
+ let scale, coverOffsetX = 0, coverOffsetY = 0;
1322
+ if (isCoverMode) {
1323
+ scale = Math.max(displayedWidth / actualImageWidth, displayedHeight / actualImageHeight);
1324
+ const scaledWidth = actualImageWidth * scale;
1325
+ const scaledHeight = actualImageHeight * scale;
1326
+ coverOffsetX = (scaledWidth - displayedWidth) / 2;
1327
+ coverOffsetY = (scaledHeight - displayedHeight) / 2;
1328
+ } else {
1329
+ scale = actualImageWidth / displayedWidth;
1330
+ }
1331
+
1332
+ const originalUri = sourceImageUri.current || image;
1333
+ let cropMeta = null;
1334
+
1335
+ if (points.length > 0) {
1336
+ try {
1337
+ const imagePoints = points.map(point => {
1338
+ let clampedX, clampedY, origX, origY;
1339
+ if (isCoverMode) {
1340
+ clampedX = Math.max(0, Math.min(point.x, contentRect.width));
1341
+ clampedY = Math.max(0, Math.min(point.y, contentRect.height));
1342
+ origX = (clampedX + coverOffsetX) / scale;
1343
+ origY = (clampedY + coverOffsetY) / scale;
1344
+ } else {
1345
+ clampedX = Math.max(contentRect.x, Math.min(point.x, contentRect.x + contentRect.width));
1346
+ clampedY = Math.max(contentRect.y, Math.min(point.y, contentRect.y + contentRect.height));
1347
+ origX = (clampedX - contentRect.x) * scale;
1348
+ origY = (clampedY - contentRect.y) * scale;
1349
+ }
1350
+ const finalX = Math.max(0, Math.min(origX, actualImageWidth));
1351
+ const finalY = Math.max(0, Math.min(origY, actualImageHeight));
1352
+ return { x: finalX, y: finalY };
1353
+ });
1354
+
1355
+ const minX = Math.min(...imagePoints.map(p => p.x));
1356
+ const minY = Math.min(...imagePoints.map(p => p.y));
1357
+ const maxX = Math.max(...imagePoints.map(p => p.x));
1358
+ const maxY = Math.max(...imagePoints.map(p => p.y));
1359
+
1360
+ const cropX = Math.max(0, Math.floor(minX));
1361
+ const cropY = Math.max(0, Math.floor(minY));
1362
+ const cropEndX = Math.min(actualImageWidth, Math.ceil(maxX));
1363
+ const cropEndY = Math.min(actualImageHeight, Math.ceil(maxY));
1364
+ const cropWidth = Math.max(0, cropEndX - cropX);
1365
+ const cropHeight = Math.max(0, cropEndY - cropY);
1366
+
1367
+ if (cropWidth > 0 && cropHeight > 0) {
1368
+ const bbox = { x: cropX, y: cropY, width: cropWidth, height: cropHeight };
1369
+ const polygon = imagePoints.map(point => ({
1370
+ x: point.x - cropX,
1371
+ y: point.y - cropY
1372
+ }));
1373
+ cropMeta = {
1374
+ bbox,
1375
+ polygon,
1376
+ rotation: 0,
1377
+ imageSize: { width: actualImageWidth, height: actualImageHeight },
1378
+ };
1379
+ }
1380
+ } catch (cropError) {
1381
+ console.error("Error computing crop meta:", cropError);
1382
+ }
1383
+ }
1384
+
1385
+ const name = `IMAGE XTK${Date.now()}`;
1386
+ if (onConfirm) {
1387
+ onConfirm(originalUri, name, cropMeta);
1388
+ }
1389
+ } catch (error) {
1390
+ console.error("Erreur lors du crop :", error);
1391
+ alert("Erreur lors du crop ! " + error.message);
1392
+ } finally {
1393
+ setShowResult(false);
1394
+ setIsLoading(false);
1395
+ setShowFullScreenCapture(false);
1396
+ }
1397
+ }}
1398
+ >
1399
+ <Text style={styles.buttonText}>Confirm</Text>
1400
+ </TouchableOpacity>
1401
+ </View>
1402
+ )}
1403
+
1404
+ {/* ✅ Show welcome screen when no image */}
1405
+ {!showResult && !image && (
1406
+ <View style={styles.centerButtonsContainer}>
1407
+ <Text style={styles.welcomeText}>Sélectionnez une image</Text>
1408
+ </View>
1409
+ )}
1410
+ </>
1411
+ )}
1412
+
1413
+ {/* ✅ CORRECTION : Vue de masque temporaire pour la capture
1414
+ Cette vue est rendue hors écran mais nécessaire pour captureRef
1415
+ Utiliser une position négative mais pas trop éloignée pour éviter les problèmes de captureRef */}
1416
+ {showMaskView && maskImageUri && maskPoints.length > 0 && (
1417
+ <View
1418
+ style={{
1419
+ position: 'absolute',
1420
+ left: -maskDimensions.width - 100,
1421
+ top: -maskDimensions.height - 100,
1422
+ width: maskDimensions.width,
1423
+ height: maskDimensions.height,
1424
+ opacity: 1,
1425
+ pointerEvents: 'none',
1426
+ zIndex: 9999,
1427
+ overflow: 'hidden',
1428
+ }}
1429
+ collapsable={false}
1430
+ >
1431
+ <MaskView
1432
+ ref={maskViewRef}
1433
+ imageUri={maskImageUri}
1434
+ points={maskPoints}
1435
+ width={maskDimensions.width}
1436
+ height={maskDimensions.height}
1437
+ />
1438
+ </View>
1439
+ )}
1440
+
1441
+ <Modal visible={isLoading} transparent animationType="fade">
1442
+ <View style={styles.loadingOverlay}>
1443
+ <Image
1444
+ source={require('../src/assets/loadingCamera.gif')}
1445
+ style={styles.loadingGif}
1446
+ resizeMode="contain"
1447
+ />
1448
+ </View>
1449
+ </Modal>
1450
+ </View>
1451
+ );
1452
+ };
1453
+
1448
1454
  export default ImageCropper;