react-native-expo-cropper
Version:
Recadrage polygonal d'images.
1,057 lines (993 loc) • 75.6 kB
JavaScript
"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