UNPKG

react-native-expo-cropper

Version:
1,057 lines (993 loc) 75.6 kB
"use strict"; function _typeof(o) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (o) { return typeof o; } : function (o) { return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o; }, _typeof(o); } Object.defineProperty(exports, "__esModule", { value: true }); exports["default"] = void 0; var _ImageCropperStyles = _interopRequireDefault(require("./ImageCropperStyles")); var _react = _interopRequireWildcard(require("react")); var _reactNative = require("react-native"); var _reactNativeSvg = _interopRequireWildcard(require("react-native-svg")); var _CustomCamera = _interopRequireDefault(require("./CustomCamera")); var ImageManipulator = _interopRequireWildcard(require("expo-image-manipulator")); var ImagePicker = _interopRequireWildcard(require("expo-image-picker")); var _vectorIcons = require("@expo/vector-icons"); var _reactNativeSafeAreaContext = require("react-native-safe-area-context"); var _ImageMaskProcessor = require("./ImageMaskProcessor"); function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function _interopRequireWildcard(e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, "default": e }; if (null === e || "object" != _typeof(e) && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (var _t4 in e) "default" !== _t4 && {}.hasOwnProperty.call(e, _t4) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, _t4)) && (i.get || i.set) ? o(f, _t4, i) : f[_t4] = e[_t4]); return f; })(e, t); } function _interopRequireDefault(e) { return e && e.__esModule ? e : { "default": e }; } function _toConsumableArray(r) { return _arrayWithoutHoles(r) || _iterableToArray(r) || _unsupportedIterableToArray(r) || _nonIterableSpread(); } function _nonIterableSpread() { throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } function _iterableToArray(r) { if ("undefined" != typeof Symbol && null != r[Symbol.iterator] || null != r["@@iterator"]) return Array.from(r); } function _arrayWithoutHoles(r) { if (Array.isArray(r)) return _arrayLikeToArray(r); } function _regenerator() { /*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/babel/babel/blob/main/packages/babel-helpers/LICENSE */ var e, t, r = "function" == typeof Symbol ? Symbol : {}, n = r.iterator || "@@iterator", o = r.toStringTag || "@@toStringTag"; function i(r, n, o, i) { var c = n && n.prototype instanceof Generator ? n : Generator, u = Object.create(c.prototype); return _regeneratorDefine2(u, "_invoke", function (r, n, o) { var i, c, u, f = 0, p = o || [], y = !1, G = { p: 0, n: 0, v: e, a: d, f: d.bind(e, 4), d: function d(t, r) { return i = t, c = 0, u = e, G.n = r, a; } }; function d(r, n) { for (c = r, u = n, t = 0; !y && f && !o && t < p.length; t++) { var o, i = p[t], d = G.p, l = i[2]; r > 3 ? (o = l === n) && (u = i[(c = i[4]) ? 5 : (c = 3, 3)], i[4] = i[5] = e) : i[0] <= d && ((o = r < 2 && d < i[1]) ? (c = 0, G.v = n, G.n = i[1]) : d < l && (o = r < 3 || i[0] > n || n > l) && (i[4] = r, i[5] = n, G.n = l, c = 0)); } if (o || r > 1) return a; throw y = !0, n; } return function (o, p, l) { if (f > 1) throw TypeError("Generator is already running"); for (y && 1 === p && d(p, l), c = p, u = l; (t = c < 2 ? e : u) || !y;) { i || (c ? c < 3 ? (c > 1 && (G.n = -1), d(c, u)) : G.n = u : G.v = u); try { if (f = 2, i) { if (c || (o = "next"), t = i[o]) { if (!(t = t.call(i, u))) throw TypeError("iterator result is not an object"); if (!t.done) return t; u = t.value, c < 2 && (c = 0); } else 1 === c && (t = i["return"]) && t.call(i), c < 2 && (u = TypeError("The iterator does not provide a '" + o + "' method"), c = 1); i = e; } else if ((t = (y = G.n < 0) ? u : r.call(n, G)) !== a) break; } catch (t) { i = e, c = 1, u = t; } finally { f = 1; } } return { value: t, done: y }; }; }(r, o, i), !0), u; } var a = {}; function Generator() {} function GeneratorFunction() {} function GeneratorFunctionPrototype() {} t = Object.getPrototypeOf; var c = [][n] ? t(t([][n]())) : (_regeneratorDefine2(t = {}, n, function () { return this; }), t), u = GeneratorFunctionPrototype.prototype = Generator.prototype = Object.create(c); function f(e) { return Object.setPrototypeOf ? Object.setPrototypeOf(e, GeneratorFunctionPrototype) : (e.__proto__ = GeneratorFunctionPrototype, _regeneratorDefine2(e, o, "GeneratorFunction")), e.prototype = Object.create(u), e; } return GeneratorFunction.prototype = GeneratorFunctionPrototype, _regeneratorDefine2(u, "constructor", GeneratorFunctionPrototype), _regeneratorDefine2(GeneratorFunctionPrototype, "constructor", GeneratorFunction), GeneratorFunction.displayName = "GeneratorFunction", _regeneratorDefine2(GeneratorFunctionPrototype, o, "GeneratorFunction"), _regeneratorDefine2(u), _regeneratorDefine2(u, o, "Generator"), _regeneratorDefine2(u, n, function () { return this; }), _regeneratorDefine2(u, "toString", function () { return "[object Generator]"; }), (_regenerator = function _regenerator() { return { w: i, m: f }; })(); } function _regeneratorDefine2(e, r, n, t) { var i = Object.defineProperty; try { i({}, "", {}); } catch (e) { i = 0; } _regeneratorDefine2 = function _regeneratorDefine(e, r, n, t) { function o(r, n) { _regeneratorDefine2(e, r, function (e) { return this._invoke(r, n, e); }); } r ? i ? i(e, r, { value: n, enumerable: !t, configurable: !t, writable: !t }) : e[r] = n : (o("next", 0), o("throw", 1), o("return", 2)); }, _regeneratorDefine2(e, r, n, t); } function asyncGeneratorStep(n, t, e, r, o, a, c) { try { var i = n[a](c), u = i.value; } catch (n) { return void e(n); } i.done ? t(u) : Promise.resolve(u).then(r, o); } function _asyncToGenerator(n) { return function () { var t = this, e = arguments; return new Promise(function (r, o) { var a = n.apply(t, e); function _next(n) { asyncGeneratorStep(a, r, o, _next, _throw, "next", n); } function _throw(n) { asyncGeneratorStep(a, r, o, _next, _throw, "throw", n); } _next(void 0); }); }; } function _slicedToArray(r, e) { return _arrayWithHoles(r) || _iterableToArrayLimit(r, e) || _unsupportedIterableToArray(r, e) || _nonIterableRest(); } function _nonIterableRest() { throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } function _unsupportedIterableToArray(r, a) { if (r) { if ("string" == typeof r) return _arrayLikeToArray(r, a); var t = {}.toString.call(r).slice(8, -1); return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0; } } function _arrayLikeToArray(r, a) { (null == a || a > r.length) && (a = r.length); for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e]; return n; } function _iterableToArrayLimit(r, l) { var t = null == r ? null : "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"]; if (null != t) { var e, n, i, u, a = [], f = !0, o = !1; try { if (i = (t = t.call(r)).next, 0 === l) { if (Object(t) !== t) return; f = !1; } else for (; !(f = (e = i.call(t)).done) && (a.push(e.value), a.length !== l); f = !0); } catch (r) { o = !0, n = r; } finally { try { if (!f && null != t["return"] && (u = t["return"](), Object(u) !== u)) return; } finally { if (o) throw n; } } return a; } } function _arrayWithHoles(r) { if (Array.isArray(r)) return r; } var PRIMARY_GREEN = '#689F38'; var ACCENT_GREEN = '#689F38'; var TUTORIAL_IMAGE_MAP = { seedcount: require('./assets/preview-pic.png'), trackseeds: require('./assets/preview-trackseeds.png'), fruitcheck: require('./assets/preview-fruitcheck.png') }; var TUTORIAL_IMAGE_ALIASES = { seedcount: 'seedcount', 'seed-count': 'seedcount', trackseeds: 'trackseeds', 'track-seeds': 'trackseeds', fruitcheck: 'fruitcheck', 'fruit-check': 'fruitcheck' }; // Base dimension for responsive scaling (e.g. ~375pt design width) var RESPONSIVE_BASE = 375; var ImageCropper = function ImageCropper(_ref) { var _cameraFrameData$curr3; var onConfirm = _ref.onConfirm, openCameraFirst = _ref.openCameraFirst, initialImage = _ref.initialImage, addheight = _ref.addheight, rotationLabel = _ref.rotationLabel, onCancel = _ref.onCancel, _ref$cancelText = _ref.cancelText, cancelText = _ref$cancelText === void 0 ? 'Cancel' : _ref$cancelText, _ref$confirmText = _ref.confirmText, confirmText = _ref$confirmText === void 0 ? 'Use Scan' : _ref$confirmText, _ref$resetText = _ref.resetText, resetText = _ref$resetText === void 0 ? 'Enhance' : _ref$resetText, _ref$rotateText = _ref.rotateText, rotateText = _ref$rotateText === void 0 ? 'Rotate' : _ref$rotateText, _ref$cameraInstructio = _ref.cameraInstructionText, cameraInstructionText = _ref$cameraInstructio === void 0 ? 'Place the tray inside the green box' : _ref$cameraInstructio, _ref$tutorialSkipText = _ref.tutorialSkipText, tutorialSkipText = _ref$tutorialSkipText === void 0 ? 'Skip' : _ref$tutorialSkipText, _ref$tutorialContinue = _ref.tutorialContinueText, tutorialContinueText = _ref$tutorialContinue === void 0 ? 'Continue' : _ref$tutorialContinue, _ref$tutorial = _ref.tutorial, tutorial = _ref$tutorial === void 0 ? '' : _ref$tutorial, onSkipTutorial = _ref.onSkipTutorial, _ref$showTutorial = _ref.showTutorial, showTutorial = _ref$showTutorial === void 0 ? true : _ref$showTutorial; var _useWindowDimensions = (0, _reactNative.useWindowDimensions)(), windowWidth = _useWindowDimensions.width, windowHeight = _useWindowDimensions.height; // Responsive scale: image and UI adapt to small (phone) and large (tablet) screens var responsiveScale = Math.max(0.65, Math.min(2, Math.min(windowWidth, windowHeight) / RESPONSIVE_BASE)); var normalizedTutorial = String(tutorial || '').trim().toLowerCase(); var tutorialKey = TUTORIAL_IMAGE_ALIASES[normalizedTutorial] || normalizedTutorial.replace(/[^a-z0-9]/g, ''); var tutorialImageSource = tutorialKey ? TUTORIAL_IMAGE_MAP[tutorialKey] : null; var guessExtensionFromUri = function guessExtensionFromUri(uri) { if (!uri || typeof uri !== 'string') return null; var match = uri.match(/\.([a-zA-Z0-9]+)(?:[?#].*)?$/); if (!match) return null; var ext = match[1].toLowerCase(); // Keep only common image extensions if (['jpg', 'jpeg', 'png', 'webp', 'heic', 'heif'].includes(ext)) return ext; return null; }; var normalizeGalleryDimensions = function normalizeGalleryDimensions(imgWidth, imgHeight, uri) { var width = imgWidth; var height = imgHeight; // Some Android gallery/ImagePicker URIs report density-scaled dimensions (e.g. 1512x2016 // instead of 3024x4032 on a 2x screen). Correct to physical pixels when it is likely safe. if (_reactNative.Platform.OS === 'android' && uri && typeof uri === 'string') { var pixelRatio = _reactNative.PixelRatio.get(); var roundedRatio = Math.max(1, Math.round(pixelRatio)); var isLikelyImagePickerCacheUri = uri.includes('/cache/ImagePicker/') || uri.includes('/ImagePicker/'); var isPotentiallyScaled = roundedRatio > 1 && Math.max(width, height) <= 2200; if (isLikelyImagePickerCacheUri && isPotentiallyScaled) { var scaledWidth = Math.round(width * roundedRatio); var scaledHeight = Math.round(height * roundedRatio); // Keep safety bounds to avoid incorrect inflation on genuinely small images. if (scaledWidth >= width && scaledHeight >= height && scaledWidth <= 12000 && scaledHeight <= 12000) { width = scaledWidth; height = scaledHeight; console.log("✅ Corrected Android gallery dimensions to physical pixels:", { original: { width: imgWidth, height: imgHeight }, corrected: { width: width, height: height }, pixelRatio: pixelRatio, uri: uri }); } } } return { width: width, height: height }; }; var _useState = (0, _react.useState)(null), _useState2 = _slicedToArray(_useState, 2), image = _useState2[0], setImage = _useState2[1]; var _useState3 = (0, _react.useState)([]), _useState4 = _slicedToArray(_useState3, 2), points = _useState4[0], setPoints = _useState4[1]; var _useState5 = (0, _react.useState)(false), _useState6 = _slicedToArray(_useState5, 2), showResult = _useState6[0], setShowResult = _useState6[1]; var _useState7 = (0, _react.useState)(false), _useState8 = _slicedToArray(_useState7, 2), showCustomCamera = _useState8[0], setShowCustomCamera = _useState8[1]; var _useState9 = (0, _react.useState)(false), _useState0 = _slicedToArray(_useState9, 2), showCameraTutorial = _useState0[0], setShowCameraTutorial = _useState0[1]; var viewRef = (0, _react.useRef)(null); var maskViewRef = (0, _react.useRef)(null); // Ref pour la vue de masque (invisible) var sourceImageUri = (0, _react.useRef)(null); // keep original image URI (full-res) for upload var cameraFrameData = (0, _react.useRef)(null); // ✅ Store green frame coordinates from camera // ✅ RÉFÉRENTIEL UNIQUE : Wrapper commun 9/16 (identique à CustomCamera) // Ce wrapper est utilisé dans CustomCamera ET ImageCropper pour garantir pixel-perfect matching var commonWrapperRef = (0, _react.useRef)(null); var commonWrapperLayout = (0, _react.useRef)({ x: 0, y: 0, width: 0, height: 0 }); // ✅ REFACTORISATION : Séparation claire entre dimensions originales et affichage // Dimensions réelles de l'image originale (pixels) var originalImageDimensions = (0, _react.useRef)({ width: 0, height: 0 }); // Dimensions et position d'affichage à l'écran (pour le calcul des points de crop) var displayedImageLayout = (0, _react.useRef)({ x: 0, y: 0, width: 0, height: 0 }); // Conserver imageMeasure pour compatibilité avec le code existant (utilisé pour SVG overlay) var imageMeasure = (0, _react.useRef)({ x: 0, y: 0, width: 0, height: 0 }); // ✅ imageDisplayRect : Rectangle réel de l'image affichée (quand resizeMode='contain') à l'intérieur du wrapper commun // C'est la zone où l'image est réellement visible (avec letterboxing si nécessaire) // Les points de crop DOIVENT rester dans cette zone pour éviter de cropper hors de l'image var imageDisplayRect = (0, _react.useRef)({ x: 0, y: 0, width: 0, height: 0 }); // ✅ COMPATIBILITÉ : displayedContentRect reste pour le code existant, mais pointe vers imageDisplayRect var displayedContentRect = imageDisplayRect; // ✅ RÉFÉRENTIEL UNIQUE : Calculer imageDisplayRect à l'intérieur du wrapper commun // - CAMERA: use "cover" mode → image fills wrapper, imageDisplayRect = full wrapper (same as preview) // - GALLERY: use "contain" mode → imageDisplayRect = letterboxed area var updateImageDisplayRect = function updateImageDisplayRect(wrapperWidth, wrapperHeight) { var iw = originalImageDimensions.current.width; var ih = originalImageDimensions.current.height; // ✅ CAMERA IMAGE: Use full wrapper so green frame and white frame show same content if (cameraFrameData.current && cameraFrameData.current.greenFrame && wrapperWidth > 0 && wrapperHeight > 0) { imageDisplayRect.current = { x: 0, y: 0, width: wrapperWidth, height: wrapperHeight }; console.log("✅ Image display rect (COVER mode for camera - full wrapper):", imageDisplayRect.current); return; } console.log("🔄 updateImageDisplayRect called:", { originalDimensions: { width: iw, height: ih }, wrapperDimensions: { width: wrapperWidth, height: wrapperHeight } }); if (iw > 0 && ih > 0 && wrapperWidth > 0 && wrapperHeight > 0) { // Calculer comment l'image s'affiche en "contain" dans le wrapper (gallery) var scale = Math.min(wrapperWidth / iw, wrapperHeight / ih); var imageDisplayWidth = iw * scale; var imageDisplayHeight = ih * scale; var offsetX = (wrapperWidth - imageDisplayWidth) / 2; var offsetY = (wrapperHeight - imageDisplayHeight) / 2; imageDisplayRect.current = { x: offsetX, y: offsetY, width: imageDisplayWidth, height: imageDisplayHeight }; console.log("✅ Image display rect (contain in wrapper) calculated:", { wrapper: { width: wrapperWidth, height: wrapperHeight }, imageDisplayRect: imageDisplayRect.current, scale: scale.toFixed(4) }); return; } if (wrapperWidth > 0 && wrapperHeight > 0) { imageDisplayRect.current = { x: 0, y: 0, width: wrapperWidth, height: wrapperHeight }; console.log("⚠️ Using wrapper dimensions as fallback (original dimensions not available yet):", imageDisplayRect.current); } else { imageDisplayRect.current = { x: 0, y: 0, width: 0, height: 0 }; console.warn("❌ Cannot calculate imageDisplayRect: missing dimensions"); } }; // ✅ COMPATIBILITÉ : Alias pour le code existant var updateDisplayedContentRect = updateImageDisplayRect; var selectedPointIndex = (0, _react.useRef)(null); var lastTap = (0, _react.useRef)(null); // ✅ CRITICAL: Guard to prevent double crop box initialization // This ensures crop box is initialized only once, especially for camera images var hasInitializedCropBox = (0, _react.useRef)(false); var imageSource = (0, _react.useRef)(null); // 'camera' | 'gallery' | null // ✅ FREE DRAG: Store initial touch position and point position for delta-based movement var initialTouchPosition = (0, _react.useRef)(null); // { x, y } - initial touch position when drag starts var initialPointPosition = (0, _react.useRef)(null); // { x, y } - initial point position when drag starts var lastTouchPosition = (0, _react.useRef)(null); // { x, y } - last touch position for incremental delta calculation // ✅ CRITICAL: dragBase stores the VISUAL position (can be overshoot) during drag // This ensures smooth continuous drag without "dead zones" at boundaries // dragBase is updated with applied position (overshoot) after each movement // and is used as base for next delta calculation var dragBase = (0, _react.useRef)(null); // { x, y } - visual position during drag (can be overshoot) // ✅ NEW APPROACH: touchOffset stores the initial offset between point and touch // This eliminates delta accumulation issues and "dead zones" // Once set at drag start, it remains constant throughout the drag var touchOffset = (0, _react.useRef)(null); // { x, y } - offset = pointPosition - touchPosition // ✅ Track if point was clamped in previous frame (to detect transition) var wasClampedLastFrame = (0, _react.useRef)({ x: false, y: false }); // Angle de rotation accumulé (pour éviter les rotations multiples) var rotationAngle = (0, _react.useRef)(0); // États pour la vue de masque temporaire var _useState1 = (0, _react.useState)(null), _useState10 = _slicedToArray(_useState1, 2), maskImageUri = _useState10[0], setMaskImageUri = _useState10[1]; var _useState11 = (0, _react.useState)([]), _useState12 = _slicedToArray(_useState11, 2), maskPoints = _useState12[0], setMaskPoints = _useState12[1]; var _useState13 = (0, _react.useState)({ width: 0, height: 0 }), _useState14 = _slicedToArray(_useState13, 2), maskDimensions = _useState14[0], setMaskDimensions = _useState14[1]; var _useState15 = (0, _react.useState)(false), _useState16 = _slicedToArray(_useState15, 2), showMaskView = _useState16[0], setShowMaskView = _useState16[1]; var _useState17 = (0, _react.useState)(false), _useState18 = _slicedToArray(_useState17, 2), isLoading = _useState18[0], setIsLoading = _useState18[1]; var _useState19 = (0, _react.useState)(false), _useState20 = _slicedToArray(_useState19, 2), showFullScreenCapture = _useState20[0], setShowFullScreenCapture = _useState20[1]; var _useState21 = (0, _react.useState)(false), _useState22 = _slicedToArray(_useState21, 2), isRotating = _useState22[0], setIsRotating = _useState22[1]; var cameraLaunchInProgress = (0, _react.useRef)(false); var rotationInProgressRef = (0, _react.useRef)(false); // block duplicate taps immediately var lastValidPosition = (0, _react.useRef)(null); var insets = (0, _reactNativeSafeAreaContext.useSafeAreaInsets)(); // ✅ NEW ARCH: mobile does NOT export the final crop. // No view-shot / captureRef / bitmap masking on device. var enableMask = false; var enableRotation = true; // rotation resets crop box so it is re-initialized on rotated image. var launchNativeCamera = /*#__PURE__*/function () { var _ref2 = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee() { var permission, result, capturedAsset, _t; return _regenerator().w(function (_context) { while (1) switch (_context.p = _context.n) { case 0: if (!cameraLaunchInProgress.current) { _context.n = 1; break; } return _context.a(2); case 1: cameraLaunchInProgress.current = true; setShowCameraTutorial(false); _context.p = 2; _context.n = 3; return ImagePicker.requestCameraPermissionsAsync(); case 3: permission = _context.v; if (permission.granted) { _context.n = 4; break; } console.warn("Camera permission denied for launchCameraAsync"); alert("Camera permission denied. Please allow camera access."); setShowCameraTutorial(showTutorial); return _context.a(2); case 4: _context.n = 5; return ImagePicker.launchCameraAsync({ mediaTypes: ['images'], allowsEditing: false, quality: 1, exif: true }); case 5: result = _context.v; if (!(result.canceled || !result.assets || result.assets.length === 0)) { _context.n = 6; break; } console.log("Native camera canceled by user"); setShowCameraTutorial(showTutorial); return _context.a(2); case 6: capturedAsset = result.assets[0]; if (capturedAsset !== null && capturedAsset !== void 0 && capturedAsset.uri) { _context.n = 7; break; } throw new Error("No image URI returned by launchCameraAsync"); case 7: // Native camera app capture should be treated like gallery/image input in cropper. cameraFrameData.current = null; imageSource.current = 'nativeCamera'; hasInitializedCropBox.current = false; sourceImageUri.current = capturedAsset.uri; rotationAngle.current = 0; setPoints([]); if (capturedAsset.width && capturedAsset.height) { originalImageDimensions.current = { width: capturedAsset.width, height: capturedAsset.height }; } setImage(capturedAsset.uri); setShowCustomCamera(false); console.log("✅ Native camera image selected:", { width: capturedAsset.width, height: capturedAsset.height, uri: capturedAsset.uri }); _context.n = 9; break; case 8: _context.p = 8; _t = _context.v; console.error("Error launching native camera:", _t); alert("Impossible d'ouvrir la cam\xE9ra native: ".concat((_t === null || _t === void 0 ? void 0 : _t.message) || 'Erreur inconnue')); setShowCameraTutorial(showTutorial); case 9: _context.p = 9; cameraLaunchInProgress.current = false; return _context.f(9); case 10: return _context.a(2); } }, _callee, null, [[2, 8, 9, 10]]); })); return function launchNativeCamera() { return _ref2.apply(this, arguments); }; }(); var handleSkipTutorial = function handleSkipTutorial() { if (typeof onSkipTutorial === 'function') { onSkipTutorial(false); } setShowCameraTutorial(false); launchNativeCamera(); }; (0, _react.useEffect)(function () { if (openCameraFirst) { setShowCustomCamera(false); if (showTutorial) { setShowCameraTutorial(true); } else { setShowCameraTutorial(false); launchNativeCamera(); } return; } if (initialImage) { setImage(initialImage); sourceImageUri.current = initialImage; // ✅ CRITICAL: Reset points when loading a new image from gallery // This ensures the crop box will be automatically initialized setPoints([]); rotationAngle.current = 0; // Clear camera frame data for gallery images cameraFrameData.current = null; // ✅ CRITICAL: Reset initialization guard for new image hasInitializedCropBox.current = false; imageSource.current = null; setShowCameraTutorial(false); } }, [openCameraFirst, initialImage, showTutorial]); // ✅ REFACTORISATION : Stocker uniquement les dimensions originales (pas de calcul théorique) // ✅ CRITICAL FIX: Single source of truth for image dimensions (0, _react.useEffect)(function () { if (!image) { originalImageDimensions.current = { width: 0, height: 0 }; hasInitializedCropBox.current = false; imageSource.current = null; return; } if (!sourceImageUri.current) { sourceImageUri.current = image; } // ✅ Preserve dimensions returned by launchCameraAsync. // On some Android devices, Image.getSize() for this URI can return half-resolution values. if (imageSource.current === 'nativeCamera' && originalImageDimensions.current.width > 0 && originalImageDimensions.current.height > 0) { var _originalImageDimensi = originalImageDimensions.current, nativeWidth = _originalImageDimensi.width, nativeHeight = _originalImageDimensi.height; hasInitializedCropBox.current = false; var wrapper = commonWrapperLayout.current; if (wrapper.width > 0 && wrapper.height > 0) { updateImageDisplayRect(wrapper.width, wrapper.height); } console.log("✅ Using native camera dimensions from launchCameraAsync (skip Image.getSize):", { width: nativeWidth, height: nativeHeight, source: 'launchCameraAsync' }); return; } // ✅ CRITICAL FIX #1: If we have capturedImageSize from camera, use it as SINGLE SOURCE OF TRUTH // takePictureAsync returns physical dimensions, while Image.getSize() may return EXIF-oriented dimensions // DO NOT call Image.getSize() for camera images - it can return swapped dimensions on Android if (cameraFrameData.current && cameraFrameData.current.capturedImageSize) { var _cameraFrameData$curr = cameraFrameData.current.capturedImageSize, capturedWidth = _cameraFrameData$curr.width, capturedHeight = _cameraFrameData$curr.height; originalImageDimensions.current = { width: capturedWidth, height: capturedHeight }; imageSource.current = 'camera'; hasInitializedCropBox.current = false; // Reset guard for new camera image console.log("✅ Using captured image dimensions from takePictureAsync (SINGLE SOURCE OF TRUTH):", { width: capturedWidth, height: capturedHeight, source: 'takePictureAsync', note: 'Image.getSize() will NOT be called for camera images' }); // ✅ CRITICAL: Recalculate imageDisplayRect immediately with camera dimensions var _wrapper = commonWrapperLayout.current; if (_wrapper.width > 0 && _wrapper.height > 0) { updateImageDisplayRect(_wrapper.width, _wrapper.height); // ✅ CRITICAL FIX #2: Initialize crop box immediately when cameraFrameData is available // This ensures camera images are initialized from greenFrame BEFORE any other initialization if (cameraFrameData.current && cameraFrameData.current.greenFrame && !hasInitializedCropBox.current) { console.log("✅ Initializing crop box from cameraFrameData (immediate in useEffect):", { hasGreenFrame: !!cameraFrameData.current.greenFrame, wrapper: _wrapper, originalDimensions: originalImageDimensions.current }); initializeCropBox(); } } return; // ✅ CRITICAL: Exit early - DO NOT call Image.getSize() } // ✅ FALLBACK: Use Image.getSize() ONLY for gallery images (no cameraFrameData) // BUT: Check again right before calling to avoid race condition if (cameraFrameData.current && cameraFrameData.current.capturedImageSize) { console.log("⚠️ cameraFrameData exists, skipping Image.getSize() call"); return; } // ✅ CRITICAL: Also check imageSource - if it's 'camera', don't call Image.getSize() if (imageSource.current === 'camera') { console.log("⚠️ imageSource is 'camera', skipping Image.getSize() call"); return; } // ✅ CRITICAL: Set imageSource to 'gallery' ONLY if we're sure it's not a camera image // Don't set it yet - we'll set it in the callback after verifying hasInitializedCropBox.current = false; // Reset guard for new image _reactNative.Image.getSize(image, function (imgWidth, imgHeight) { // ✅ CRITICAL SAFETY #1: Check if cameraFrameData appeared while Image.getSize() was resolving // This is the PRIMARY check - cameraFrameData takes precedence if (cameraFrameData.current && cameraFrameData.current.capturedImageSize) { console.warn("⚠️ Image.getSize() resolved but cameraFrameData exists - IGNORING Image.getSize() result to prevent dimension swap"); console.warn("⚠️ Camera dimensions (correct):", cameraFrameData.current.capturedImageSize); console.warn("⚠️ Image.getSize() dimensions (potentially swapped):", { width: imgWidth, height: imgHeight }); return; // ✅ CRITICAL: Exit early - do NOT update dimensions or initialize crop box } // ✅ CRITICAL SAFETY #2: Check imageSource (should be 'camera' if cameraFrameData was set) if (imageSource.current === 'camera') { console.warn("⚠️ Image.getSize() resolved but imageSource is 'camera' - IGNORING Image.getSize() result"); console.warn("⚠️ Image.getSize() dimensions (potentially swapped):", { width: imgWidth, height: imgHeight }); return; // ✅ CRITICAL: Exit early - do NOT update dimensions or initialize crop box } // ✅ CRITICAL SAFETY #3: Check if crop box was already initialized (from camera) if (hasInitializedCropBox.current) { console.warn("⚠️ Image.getSize() resolved but crop box already initialized - IGNORING result to prevent double initialization"); return; } // ✅ SAFE: This is a gallery image, proceed with Image.getSize() result imageSource.current = 'gallery'; var normalizedDims = normalizeGalleryDimensions(imgWidth, imgHeight, image); originalImageDimensions.current = { width: normalizedDims.width, height: normalizedDims.height }; console.log("✅ Image dimensions from Image.getSize() (gallery image):", { width: normalizedDims.width, height: normalizedDims.height, rawWidth: imgWidth, rawHeight: imgHeight, platform: _reactNative.Platform.OS, pixelRatio: _reactNative.PixelRatio.get(), uri: image, source: 'Image.getSize()' }); // ✅ RÉFÉRENTIEL UNIQUE : Recalculer imageDisplayRect dans le wrapper commun // dès qu'on connaît la taille originale de l'image var wrapper = commonWrapperLayout.current; if (wrapper.width > 0 && wrapper.height > 0) { updateImageDisplayRect(wrapper.width, wrapper.height); // ✅ IMPORTANT: pour les images de la galerie (pas de cameraFrameData), // initialiser automatiquement le cadre blanc (70% du wrapper) une fois que // nous connaissons à la fois le wrapper et les dimensions originales. // ✅ CRITICAL: Guard against double initialization if (!hasInitializedCropBox.current && points.length === 0 && imageSource.current === 'gallery') { initializeCropBox(); hasInitializedCropBox.current = true; } } }, function (error) { console.error("Error getting image size:", error); }); }, [image]); // Le cadre blanc doit être calculé sur le MÊME wrapper que le cadre vert (9/16) // Ensuite, on restreint les points pour qu'ils restent dans imageDisplayRect (image visible) var initializeCropBox = function initializeCropBox() { // ✅ CRITICAL FIX #2: Guard against double initialization if (hasInitializedCropBox.current) { console.log("⚠️ Crop box already initialized, skipping duplicate initialization"); return; } // ✅ CRITICAL: Ensure common wrapper layout is available var wrapper = commonWrapperLayout.current; if (wrapper.width === 0 || wrapper.height === 0) { console.warn("Cannot initialize crop box: common wrapper layout not ready"); return; } // ✅ CRITICAL: Ensure imageDisplayRect is available (zone réelle de l'image dans le wrapper) var imageRect = imageDisplayRect.current; if (imageRect.width === 0 || imageRect.height === 0) { // Recalculer si nécessaire if (originalImageDimensions.current.width > 0 && originalImageDimensions.current.height > 0) { updateImageDisplayRect(wrapper.width, wrapper.height); imageRect = imageDisplayRect.current; } else { console.warn("Cannot initialize crop box: imageDisplayRect not available (original dimensions missing)"); return; } } // ✅ CRITICAL FIX: Calculate crop box as percentage of VISIBLE IMAGE AREA (imageDisplayRect) // in WRAPPER-RELATIVE coordinates (same space as points/touches and SVG). var imageRectX = imageRect.x; var imageRectY = imageRect.y; // ✅ DEFAULT: Crop box (70% of VISIBLE IMAGE AREA - centered) var boxWidth = imageRect.width * 0.70; // 70% of visible image width var boxHeight = imageRect.height * 0.70; // 70% of visible image height var boxX = imageRectX + (imageRect.width - boxWidth) / 2; // Centered in image area var boxY = imageRectY + (imageRect.height - boxHeight) / 2; // Centered in image area // ✅ SAFETY: Validate calculated coordinates before creating points var isValidCoordinate = function isValidCoordinate(val) { return typeof val === 'number' && isFinite(val) && !isNaN(val); }; if (!isValidCoordinate(boxX) || !isValidCoordinate(boxY) || !isValidCoordinate(boxWidth) || !isValidCoordinate(boxHeight)) { console.warn("⚠️ Invalid coordinates calculated for default crop box, skipping initialization"); return; } var newPoints = [{ x: boxX, y: boxY }, { x: boxX + boxWidth, y: boxY }, { x: boxX + boxWidth, y: boxY + boxHeight }, { x: boxX, y: boxY + boxHeight }]; // ✅ SAFETY: Validate all points before setting var validPoints = newPoints.filter(function (p) { return isValidCoordinate(p.x) && isValidCoordinate(p.y); }); if (validPoints.length !== newPoints.length) { console.warn("⚠️ Some points have invalid coordinates in default crop box, skipping initialization"); return; } console.log("✅ Initializing crop box (default - 70% of visible image area, gallery only):", { wrapper: { width: wrapper.width, height: wrapper.height }, imageDisplayRect: imageRect, boxInWrapperCoords: { x: boxX, y: boxY, width: boxWidth, height: boxHeight }, points: newPoints }); setPoints(newPoints); hasInitializedCropBox.current = true; // ✅ CRITICAL: Mark as initialized }; // ✅ RÉFÉRENTIEL UNIQUE : Callback pour mettre à jour le layout du wrapper commun // Ce wrapper a exactement les mêmes dimensions que le wrapper de CustomCamera (9/16, width = screenWidth) var onCommonWrapperLayout = function onCommonWrapperLayout(e) { var layout = e.nativeEvent.layout; commonWrapperLayout.current = { x: layout.x, y: layout.y, width: layout.width, height: layout.height }; console.log("✅ Common wrapper layout updated:", commonWrapperLayout.current); // ✅ Recalculer imageDisplayRect dès que le wrapper est prêt if (originalImageDimensions.current.width > 0 && originalImageDimensions.current.height > 0) { updateImageDisplayRect(layout.width, layout.height); // ✅ CRITICAL FIX #2: Initialize crop box ONLY if not already initialized // For camera images: initialize ONLY from greenFrame (already done when cameraFrameData was set) if (!hasInitializedCropBox.current && points.length === 0) { // ✅ CRITICAL: Only initialize for gallery images here // Camera images should be initialized when cameraFrameData is set, not here if (imageSource.current !== 'camera') { initializeCropBox(); } } } }; // ✅ REFACTORISATION : Mettre à jour les dimensions d'affichage et les dimensions pour SVG var onImageLayout = function onImageLayout(e) { var layout = e.nativeEvent.layout; // Stocker les dimensions d'affichage réelles (pour conversion de coordonnées) displayedImageLayout.current = { x: layout.x, y: layout.y, width: layout.width, height: layout.height }; // Conserver aussi dans imageMeasure pour compatibilité avec SVG overlay imageMeasure.current = { x: layout.x, y: layout.y, width: layout.width, height: layout.height }; // ✅ Si l'image vient de la caméra et que les dimensions originales ne sont pas encore définies, // les initialiser à partir de cameraFrameData AVANT de calculer le contentRect. if (originalImageDimensions.current.width === 0 && cameraFrameData.current && cameraFrameData.current.capturedImageSize) { var _cameraFrameData$curr2 = cameraFrameData.current.capturedImageSize, width = _cameraFrameData$curr2.width, height = _cameraFrameData$curr2.height; originalImageDimensions.current = { width: width, height: height }; console.log("✅ originalImageDimensions initialisées depuis cameraFrameData dans onImageLayout:", { width: width, height: height }); } // ✅ RÉFÉRENTIEL UNIQUE : Recalculer imageDisplayRect dans le wrapper commun // Si le wrapper commun n'est pas encore prêt, on attendra onCommonWrapperLayout var wrapper = commonWrapperLayout.current; if (wrapper.width > 0 && wrapper.height > 0) { updateImageDisplayRect(wrapper.width, wrapper.height); } console.log("Displayed image layout updated:", { width: layout.width, height: layout.height, x: layout.x, y: layout.y }); // ✅ CRITICAL FIX #2: Do NOT initialize crop box in onImageLayout for camera images // Camera images should be initialized ONLY when cameraFrameData is set (in useEffect) // Gallery images can be initialized here if not already done if (wrapper.width > 0 && wrapper.height > 0 && layout.width > 0 && layout.height > 0 && !hasInitializedCropBox.current && points.length === 0 && originalImageDimensions.current.width > 0 && originalImageDimensions.current.height > 0) { // ✅ CRITICAL: Only initialize for gallery images here // Camera images should be initialized when cameraFrameData is set, not here if (imageSource.current !== 'camera') { initializeCropBox(); } else if (cameraFrameData.current && cameraFrameData.current.greenFrame) { // ✅ For camera images, initialize ONLY if greenFrame is available initializeCropBox(); } } }; var createPath = function createPath() { if (points.length < 1) return ''; var path = "M ".concat(points[0].x, " ").concat(points[0].y, " "); points.forEach(function (point) { return path += "L ".concat(point.x, " ").concat(point.y, " "); }); return path + 'Z'; }; // ✅ Helper function: Find closest point on a line segment to a tap point var findClosestPointOnLine = function findClosestPointOnLine(tapX, tapY, lineStartX, lineStartY, lineEndX, lineEndY) { var dx = lineEndX - lineStartX; var dy = lineEndY - lineStartY; var lengthSquared = dx * dx + dy * dy; if (lengthSquared === 0) { // Line segment is a point return { x: lineStartX, y: lineStartY, distance: Math.sqrt(Math.pow(tapX - lineStartX, 2) + Math.pow(tapY - lineStartY, 2)) }; } // Calculate projection parameter t (0 to 1) var t = Math.max(0, Math.min(1, ((tapX - lineStartX) * dx + (tapY - lineStartY) * dy) / lengthSquared)); // Calculate closest point on line segment var closestX = lineStartX + t * dx; var closestY = lineStartY + t * dy; // Calculate distance from tap to closest point var distance = Math.sqrt(Math.pow(tapX - closestX, 2) + Math.pow(tapY - closestY, 2)); return { x: closestX, y: closestY, distance: distance, t: t }; }; // ✅ Helper function: Check if tap is near any line segment and find closest point var findClosestPointOnFrame = function findClosestPointOnFrame(tapX, tapY) { var lineTolerance = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 30; if (points.length < 2) return null; var closestPoint = null; var minDistance = Infinity; var insertIndex = -1; // Check each line segment (closed polygon: last point connects to first) for (var i = 0; i < points.length; i++) { var start = points[i]; var end = points[(i + 1) % points.length]; var result = findClosestPointOnLine(tapX, tapY, start.x, start.y, end.x, end.y); if (result.distance < minDistance && result.distance < lineTolerance) { minDistance = result.distance; closestPoint = { x: result.x, y: result.y }; // Insert after the start point of this segment insertIndex = i + 1; } } return closestPoint ? { point: closestPoint, insertIndex: insertIndex } : null; }; var handleTap = function handleTap(e) { if (!image || showResult || points.length === 0) return; var now = Date.now(); var _e$nativeEvent = e.nativeEvent, tapX = _e$nativeEvent.locationX, tapY = _e$nativeEvent.locationY; var selectRadius = Math.max(18, Math.round(26 * responsiveScale)); var index = points.findIndex(function (p) { return Math.abs(p.x - tapX) < selectRadius && Math.abs(p.y - tapY) < selectRadius; }); if (index !== -1) { selectedPointIndex.current = index; initialTouchPosition.current = { x: tapX, y: tapY }; lastTouchPosition.current = { x: tapX, y: tapY }; lastTap.current = now; return; } // Restore UX: double tap near an edge inserts a new draggable point. var isDoubleTap = lastTap.current && now - lastTap.current < 300; if (isDoubleTap && points.length >= 2) { var lineResult = findClosestPointOnFrame(tapX, tapY, 30); if (lineResult) { var point = lineResult.point, insertIndex = lineResult.insertIndex; var exists = points.some(function (p) { return Math.abs(p.x - point.x) < selectRadius && Math.abs(p.y - point.y) < selectRadius; }); if (!exists) { var newPoints = _toConsumableArray(points); newPoints.splice(insertIndex, 0, point); setPoints(newPoints); lastTap.current = null; return; } } } lastTap.current = now; }; var handleMove = function handleMove(e) { if (showResult || selectedPointIndex.current === null) return; var nativeEvent = e.nativeEvent; var currentX = nativeEvent.locationX; var currentY = nativeEvent.locationY; if (currentX === undefined || currentY === undefined || isNaN(currentX) || isNaN(currentY)) return; var deltaX = 0; var deltaY = 0; if (lastTouchPosition.current) { deltaX = currentX - lastTouchPosition.current.x; deltaY = currentY - lastTouchPosition.current.y; } else if (initialTouchPosition.current) { deltaX = currentX - initialTouchPosition.current.x; deltaY = currentY - initialTouchPosition.current.y; } var imageRect = imageDisplayRect.current; var wrapper = commonWrapperLayout.current; if (imageRect.width === 0 || imageRect.height === 0) { if (wrapper.width > 0 && wrapper.height > 0 && originalImageDimensions.current.width > 0) { updateImageDisplayRect(wrapper.width, wrapper.height); imageRect = imageDisplayRect.current; } if (imageRect.width === 0 || imageRect.height === 0) { imageRect = { x: 0, y: 0, width: wrapper.width || 0, height: wrapper.height || 0 }; } } lastTouchPosition.current = { x: currentX, y: currentY }; setPoints(function (prev) { if (!Array.isArray(prev) || prev.length === 0) return prev; var pointIndex = selectedPointIndex.current; if (pointIndex === null || pointIndex < 0 || pointIndex >= prev.length) return prev; return prev.map(function (p, i) { if (i !== pointIndex) return p; return { x: Math.max(imageRect.x, Math.min(p.x + deltaX, imageRect.x + imageRect.width)), y: Math.max(imageRect.y, Math.min(p.y + deltaY, imageRect.y + imageRect.height)) }; }); }); }; var handleRelease = function handleRelease() { var wasDragging = selectedPointIndex.current !== null; // ✅ FREE DRAG: Clear initial positions when drag ends initialTouchPosition.current = null; initialPointPosition.current = null; lastTouchPosition.current = null; lastValidPosition.current = null; touchOffset.current = null; wasClampedLastFrame.current = { x: false, y: false }; selectedPointIndex.current = null; // ✅ CRITICAL: Re-enable parent ScrollView scrolling when drag ends if (wasDragging) { try { // Re-enable scrolling after a short delay to avoid conflicts setTimeout(function () { // ScrollView will be re-enabled automatically when responder is released }, 100); } catch (e) { // Ignore errors } } }; var handleReset = function handleReset() { // setPoints([]); hasInitializedCropBox.current = false; // ✅ CRITICAL: Reset guard to allow reinitialization initializeCropBox(); }; // ✅ REFACTORISATION : Stocker l'angle de rotation au lieu de modifier l'image immédiatement // La rotation sera appliquée uniquement lors du crop final pour éviter les interpolations multiples var rotatePreviewImage = /*#__PURE__*/function () { var _ref3 = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee2(degrees) { var rotated, _t2; return _regenerator().w(function (_context2) { while (1) switch (_context2.p = _context2.n) { case 0: if (image) { _context2.n = 1; break; } return _context2.a(2); case 1: if (!rotationInProgressRef.current) { _context2.n = 2; break; } return _context2.a(2); case 2: // block duplicate taps immediately (no re-render delay) rotationInProgressRef.current = true; setIsRotating(true); _context2.p = 3; rotationAngle.current = (rotationAngle.current + degrees) % 360; // Use JPEG for preview rotation (faster than PNG for large images; quality 0.92 is fine for preview) _context2.n = 4; return ImageManipulator.manipulateAsync(image, [{ rotate: degrees }], { compress: 0.92, format: ImageManipulator.SaveFormat.JPEG }); case 4: rotated = _context2.v; // ✅ Send rotated image to backend: use rotated URI and dimensions so crop bbox matches sourceImageUri.current = rotated.uri; originalImageDimensions.current = { width: rotated.width, height: rotated.height }; cameraFrameData.current = null; // rotated image is no longer "camera preview" frame imageSource.current = 'gallery'; // so layout callbacks run initializeCropBox() and show the white box setPoints([]); hasInitializedCropBox.current = fa