react-native-rectangle-doc-scanner 13.9.0 → 13.11.0

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.
@@ -6,6 +6,8 @@ interface CropEditorProps {
6
6
  overlayStrokeColor?: string;
7
7
  handlerColor?: string;
8
8
  enablePanStrict?: boolean;
9
+ enableEditor?: boolean;
10
+ autoCrop?: boolean;
9
11
  onCropChange?: (rectangle: Rectangle) => void;
10
12
  }
11
13
  /**
@@ -32,10 +32,14 @@ var __importStar = (this && this.__importStar) || (function () {
32
32
  return result;
33
33
  };
34
34
  })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
35
38
  Object.defineProperty(exports, "__esModule", { value: true });
36
39
  exports.CropEditor = void 0;
37
40
  const react_1 = __importStar(require("react"));
38
41
  const react_native_1 = require("react-native");
42
+ const react_native_perspective_image_cropper_1 = __importDefault(require("react-native-perspective-image-cropper"));
39
43
  const coordinate_1 = require("./utils/coordinate");
40
44
  /**
41
45
  * CropEditor Component
@@ -50,7 +54,7 @@ const coordinate_1 = require("./utils/coordinate");
50
54
  * @param enablePanStrict - Enable strict panning behavior
51
55
  * @param onCropChange - Callback when user adjusts crop corners
52
56
  */
53
- const CropEditor = ({ document, overlayColor = 'rgba(0,0,0,0.5)', overlayStrokeColor = '#e7a649', handlerColor = '#e7a649', enablePanStrict = false, onCropChange, }) => {
57
+ const CropEditor = ({ document, overlayColor = 'rgba(0,0,0,0.5)', overlayStrokeColor = '#e7a649', handlerColor = '#e7a649', enablePanStrict = false, enableEditor = false, autoCrop = true, onCropChange, }) => {
54
58
  const [imageSize, setImageSize] = (0, react_1.useState)(null);
55
59
  const [displaySize, setDisplaySize] = (0, react_1.useState)({
56
60
  width: react_native_1.Dimensions.get('window').width,
@@ -59,32 +63,6 @@ const CropEditor = ({ document, overlayColor = 'rgba(0,0,0,0.5)', overlayStrokeC
59
63
  const [isImageLoading, setIsImageLoading] = (0, react_1.useState)(true);
60
64
  const [loadError, setLoadError] = (0, react_1.useState)(null);
61
65
  const [croppedImageUri, setCroppedImageUri] = (0, react_1.useState)(null);
62
- (0, react_1.useEffect)(() => {
63
- console.log('[CropEditor] Document path:', document.path);
64
- console.log('[CropEditor] Document dimensions:', document.width, 'x', document.height);
65
- console.log('[CropEditor] Document quad:', document.quad);
66
- console.log('[CropEditor] Document rectangle:', document.rectangle);
67
- // Load image size using Image.getSize
68
- const imageUri = document.path.startsWith('file://') ? document.path : `file://${document.path}`;
69
- react_native_1.Image.getSize(imageUri, (width, height) => {
70
- console.log('[CropEditor] Image.getSize success:', { width, height });
71
- setImageSize({ width, height });
72
- // If we have a rectangle (from auto-capture), crop the image
73
- if (document.rectangle || document.quad) {
74
- cropImageToRectangle(imageUri, width, height);
75
- }
76
- else {
77
- setIsImageLoading(false);
78
- setLoadError(null);
79
- }
80
- }, (error) => {
81
- console.error('[CropEditor] Image.getSize error:', error);
82
- // Fallback to document dimensions
83
- console.log('[CropEditor] Using fallback dimensions:', document.width, 'x', document.height);
84
- setImageSize({ width: document.width, height: document.height });
85
- setIsImageLoading(false);
86
- });
87
- }, [document]);
88
66
  const cropImageToRectangle = (0, react_1.useCallback)((imageUri, width, height) => {
89
67
  const cropManager = react_native_1.NativeModules.CustomCropManager;
90
68
  if (!cropManager?.crop) {
@@ -127,6 +105,33 @@ const CropEditor = ({ document, overlayColor = 'rgba(0,0,0,0.5)', overlayStrokeC
127
105
  setLoadError(null);
128
106
  });
129
107
  }, [document]);
108
+ (0, react_1.useEffect)(() => {
109
+ console.log('[CropEditor] Document path:', document.path);
110
+ console.log('[CropEditor] Document dimensions:', document.width, 'x', document.height);
111
+ console.log('[CropEditor] Document quad:', document.quad);
112
+ console.log('[CropEditor] Document rectangle:', document.rectangle);
113
+ const shouldAutoCrop = autoCrop && !enableEditor;
114
+ // Load image size using Image.getSize
115
+ const imageUri = document.path.startsWith('file://') ? document.path : `file://${document.path}`;
116
+ react_native_1.Image.getSize(imageUri, (width, height) => {
117
+ console.log('[CropEditor] Image.getSize success:', { width, height });
118
+ setImageSize({ width, height });
119
+ // If we have a rectangle (from auto-capture), crop the image
120
+ if (shouldAutoCrop && (document.rectangle || document.quad)) {
121
+ cropImageToRectangle(imageUri, width, height);
122
+ }
123
+ else {
124
+ setIsImageLoading(false);
125
+ setLoadError(null);
126
+ }
127
+ }, (error) => {
128
+ console.error('[CropEditor] Image.getSize error:', error);
129
+ // Fallback to document dimensions
130
+ console.log('[CropEditor] Using fallback dimensions:', document.width, 'x', document.height);
131
+ setImageSize({ width: document.width, height: document.height });
132
+ setIsImageLoading(false);
133
+ });
134
+ }, [autoCrop, cropImageToRectangle, document, enableEditor]);
130
135
  // Get initial rectangle from detected quad or use default
131
136
  const getInitialRectangle = (0, react_1.useCallback)(() => {
132
137
  if (!imageSize) {
@@ -180,7 +185,7 @@ const CropEditor = ({ document, overlayColor = 'rgba(0,0,0,0.5)', overlayStrokeC
180
185
  react_1.default.createElement(react_native_1.Text, { style: styles.errorText }, "Failed to load image"),
181
186
  react_1.default.createElement(react_native_1.Text, { style: styles.errorPath }, imageUri))) : !imageSize || isImageLoading ? (react_1.default.createElement(react_native_1.View, { style: styles.loadingContainer },
182
187
  react_1.default.createElement(react_native_1.ActivityIndicator, { size: "large", color: handlerColor }),
183
- react_1.default.createElement(react_native_1.Text, { style: styles.loadingText }, "Loading image..."))) : (react_1.default.createElement(react_1.default.Fragment, null,
188
+ react_1.default.createElement(react_native_1.Text, { style: styles.loadingText }, "Loading image..."))) : enableEditor ? (react_1.default.createElement(react_native_perspective_image_cropper_1.default, { height: displaySize.height, width: displaySize.width, image: imageUri, rectangleCoordinates: initialRect, overlayColor: overlayColor, overlayStrokeColor: overlayStrokeColor, handlerColor: handlerColor, enablePanStrict: enablePanStrict, onDragEnd: handleDragEnd })) : (react_1.default.createElement(react_1.default.Fragment, null,
184
189
  react_1.default.createElement(react_native_1.Image, { source: { uri: croppedImageUri || imageUri }, style: styles.fullImage, resizeMode: "contain", onLoad: () => console.log('[CropEditor] Image loaded successfully', croppedImageUri ? 'cropped' : 'original'), onError: (e) => console.error('[CropEditor] Image load error:', e.nativeEvent.error) })))));
185
190
  };
186
191
  exports.CropEditor = CropEditor;
@@ -43,6 +43,7 @@ const react_native_image_picker_1 = require("react-native-image-picker");
43
43
  const react_native_image_crop_picker_1 = __importDefault(require("react-native-image-crop-picker"));
44
44
  const react_native_fs_1 = __importDefault(require("react-native-fs"));
45
45
  const DocScanner_1 = require("./DocScanner");
46
+ const coordinate_1 = require("./utils/coordinate");
46
47
  // 회전은 항상 지원됨 (회전 각도를 반환하고 tdb 앱에서 처리)
47
48
  const isImageRotationSupported = () => true;
48
49
  const stripFileUri = (value) => value.replace(/^file:\/\//, '');
@@ -58,6 +59,17 @@ const ensureFileUri = (value) => {
58
59
  }
59
60
  return value;
60
61
  };
62
+ const safeRequire = (moduleName) => {
63
+ try {
64
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
65
+ return require(moduleName);
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ };
71
+ const CropEditorModule = safeRequire('./CropEditor');
72
+ const CropEditor = CropEditorModule?.CropEditor;
61
73
  const CROPPER_TIMEOUT_MS = 8000;
62
74
  const CROPPER_TIMEOUT_CODE = 'cropper_timeout';
63
75
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -135,6 +147,8 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
135
147
  const [capturedPhotos, setCapturedPhotos] = (0, react_1.useState)([]);
136
148
  const [currentPhotoIndex, setCurrentPhotoIndex] = (0, react_1.useState)(0);
137
149
  const [scannerSession, setScannerSession] = (0, react_1.useState)(0);
150
+ const [cropEditorDocument, setCropEditorDocument] = (0, react_1.useState)(null);
151
+ const [cropEditorRectangle, setCropEditorRectangle] = (0, react_1.useState)(null);
138
152
  const resolvedGridColor = gridColor ?? overlayColor;
139
153
  const docScannerRef = (0, react_1.useRef)(null);
140
154
  const captureModeRef = (0, react_1.useRef)(null);
@@ -146,6 +160,8 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
146
160
  const resetScannerView = (0, react_1.useCallback)((options) => {
147
161
  setProcessing(false);
148
162
  setCroppedImageData(null);
163
+ setCropEditorDocument(null);
164
+ setCropEditorRectangle(null);
149
165
  setRotationDegrees(0);
150
166
  setRectangleDetected(false);
151
167
  setRectangleHint(false);
@@ -187,6 +203,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
187
203
  originalBtn: strings?.originalBtn ?? 'Use Original',
188
204
  }), [strings]);
189
205
  const pdfScannerManager = react_native_1.NativeModules?.RNPdfScannerManager;
206
+ const isAndroidCropEditorAvailable = react_native_1.Platform.OS === 'android' && Boolean(CropEditor);
190
207
  const autoEnhancementEnabled = (0, react_1.useMemo)(() => typeof pdfScannerManager?.applyColorControls === 'function', [pdfScannerManager]);
191
208
  const ensureBase64ForImage = (0, react_1.useCallback)(async (image) => {
192
209
  if (image.base64) {
@@ -243,6 +260,17 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
243
260
  react_native_1.Alert.alert('Document Scanner', fallbackMessage);
244
261
  }
245
262
  }, [onError]);
263
+ const openAndroidCropEditor = (0, react_1.useCallback)((document) => {
264
+ const rectangle = document.rectangle ??
265
+ (document.quad && document.quad.length === 4 ? (0, coordinate_1.quadToRectangle)(document.quad) : null);
266
+ const documentForEditor = rectangle ? { ...document, rectangle } : document;
267
+ setCropEditorDocument(documentForEditor);
268
+ setCropEditorRectangle(rectangle);
269
+ }, []);
270
+ const closeAndroidCropEditor = (0, react_1.useCallback)(() => {
271
+ setCropEditorDocument(null);
272
+ setCropEditorRectangle(null);
273
+ }, []);
246
274
  const openCropper = (0, react_1.useCallback)(async (imagePath, options) => {
247
275
  try {
248
276
  console.log('[FullDocScanner] openCropper called with path:', imagePath);
@@ -314,6 +342,64 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
314
342
  }
315
343
  }
316
344
  }, [cropWidth, cropHeight, emitError, preparePreviewImage, resetScannerView]);
345
+ const handleCropEditorConfirm = (0, react_1.useCallback)(async () => {
346
+ if (!cropEditorDocument) {
347
+ return;
348
+ }
349
+ const documentPath = cropEditorDocument.path;
350
+ const rectangle = cropEditorRectangle ??
351
+ cropEditorDocument.rectangle ??
352
+ (cropEditorDocument.quad && cropEditorDocument.quad.length === 4
353
+ ? (0, coordinate_1.quadToRectangle)(cropEditorDocument.quad)
354
+ : null);
355
+ if (!rectangle || !pdfScannerManager?.processImage) {
356
+ closeAndroidCropEditor();
357
+ await openCropper(documentPath, { waitForPickerDismissal: false });
358
+ return;
359
+ }
360
+ setProcessing(true);
361
+ try {
362
+ const payload = await pdfScannerManager.processImage({
363
+ imagePath: documentPath,
364
+ rectangleCoordinates: rectangle,
365
+ rectangleWidth: cropEditorDocument.width ?? 0,
366
+ rectangleHeight: cropEditorDocument.height ?? 0,
367
+ useBase64: false,
368
+ quality: 90,
369
+ brightness: 0,
370
+ contrast: 1,
371
+ saturation: 1,
372
+ saveInAppDocument: false,
373
+ });
374
+ const croppedPath = typeof payload?.croppedImage === 'string' ? stripFileUri(payload.croppedImage) : null;
375
+ if (!croppedPath) {
376
+ throw new Error('missing_cropped_image');
377
+ }
378
+ const preview = await preparePreviewImage({ path: croppedPath });
379
+ setCroppedImageData(preview);
380
+ }
381
+ catch (error) {
382
+ console.error('[FullDocScanner] Crop editor processing failed:', error);
383
+ resetScannerView({ remount: true });
384
+ emitError(error instanceof Error ? error : new Error(String(error)), 'Failed to crop image. Please try again.');
385
+ }
386
+ finally {
387
+ setProcessing(false);
388
+ closeAndroidCropEditor();
389
+ }
390
+ }, [
391
+ closeAndroidCropEditor,
392
+ cropEditorDocument,
393
+ cropEditorRectangle,
394
+ emitError,
395
+ openCropper,
396
+ pdfScannerManager,
397
+ preparePreviewImage,
398
+ resetScannerView,
399
+ ]);
400
+ const handleCropEditorCancel = (0, react_1.useCallback)(() => {
401
+ resetScannerView({ remount: true });
402
+ }, [resetScannerView]);
317
403
  const handleCapture = (0, react_1.useCallback)(async (document) => {
318
404
  console.log('[FullDocScanner] handleCapture called:', {
319
405
  origin: document.origin,
@@ -332,6 +418,15 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
332
418
  return;
333
419
  }
334
420
  const normalizedDoc = normalizeCapturedDocument(document);
421
+ const shouldOpenAndroidCropEditor = isAndroidCropEditorAvailable &&
422
+ captureMode === 'grid' &&
423
+ Boolean(normalizedDoc.rectangle ||
424
+ (normalizedDoc.quad && normalizedDoc.quad.length === 4));
425
+ if (shouldOpenAndroidCropEditor) {
426
+ console.log('[FullDocScanner] Opening Android crop editor with detected rectangle');
427
+ openAndroidCropEditor(normalizedDoc);
428
+ return;
429
+ }
335
430
  if (captureMode === 'no-grid') {
336
431
  console.log('[FullDocScanner] No grid at capture button press: opening cropper for manual selection');
337
432
  await openCropper(normalizedDoc.path, { waitForPickerDismissal: false });
@@ -358,7 +453,14 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
358
453
  }
359
454
  console.log('[FullDocScanner] Fallback to manual crop (no croppedPath available)');
360
455
  await openCropper(normalizedDoc.path, { waitForPickerDismissal: false });
361
- }, [emitError, openCropper, preparePreviewImage, resetScannerView]);
456
+ }, [
457
+ emitError,
458
+ isAndroidCropEditorAvailable,
459
+ openAndroidCropEditor,
460
+ openCropper,
461
+ preparePreviewImage,
462
+ resetScannerView,
463
+ ]);
362
464
  const triggerManualCapture = (0, react_1.useCallback)(() => {
363
465
  const scannerInstance = docScannerRef.current;
364
466
  const hasScanner = !!scannerInstance;
@@ -649,6 +751,12 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
649
751
  react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.confirmButton, styles.retakeButton], onPress: handleRetake, accessibilityLabel: mergedStrings.retake, accessibilityRole: "button" },
650
752
  react_1.default.createElement(react_native_1.Text, { style: styles.confirmButtonText }, mergedStrings.retake)),
651
753
  react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.confirmButton, styles.confirmButtonPrimary], onPress: handleConfirm, accessibilityLabel: mergedStrings.confirm, accessibilityRole: "button" },
754
+ react_1.default.createElement(react_native_1.Text, { style: styles.confirmButtonText }, mergedStrings.confirm))))) : cropEditorDocument && CropEditor ? (react_1.default.createElement(react_native_1.View, { style: styles.flex },
755
+ react_1.default.createElement(CropEditor, { document: cropEditorDocument, enableEditor: true, autoCrop: false, onCropChange: setCropEditorRectangle }),
756
+ react_1.default.createElement(react_native_1.View, { style: styles.confirmationButtons },
757
+ react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.confirmButton, styles.retakeButton], onPress: handleCropEditorCancel, accessibilityLabel: mergedStrings.retake, accessibilityRole: "button", disabled: processing },
758
+ react_1.default.createElement(react_native_1.Text, { style: styles.confirmButtonText }, mergedStrings.retake)),
759
+ react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.confirmButton, styles.confirmButtonPrimary], onPress: handleCropEditorConfirm, accessibilityLabel: mergedStrings.confirm, accessibilityRole: "button", disabled: processing },
652
760
  react_1.default.createElement(react_native_1.Text, { style: styles.confirmButtonText }, mergedStrings.confirm))))) : (react_1.default.createElement(react_native_1.View, { style: styles.flex },
653
761
  react_1.default.createElement(DocScanner_1.DocScanner, { key: scannerSession, ref: docScannerRef, autoCapture: false, overlayColor: overlayColor, showGrid: showGrid, gridColor: resolvedGridColor, gridLineWidth: gridLineWidth, minStableFrames: minStableFrames ?? 6, detectionConfig: detectionConfig, onCapture: handleCapture, onRectangleDetect: handleRectangleDetect, showManualCaptureButton: false, enableTorch: flashEnabled },
654
762
  react_1.default.createElement(react_native_1.View, { style: styles.overlayTop, pointerEvents: "box-none" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-rectangle-doc-scanner",
3
- "version": "13.9.0",
3
+ "version": "13.11.0",
4
4
  "description": "Native-backed document scanner for React Native with customizable overlays.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -26,6 +26,8 @@ interface CropEditorProps {
26
26
  overlayStrokeColor?: string;
27
27
  handlerColor?: string;
28
28
  enablePanStrict?: boolean;
29
+ enableEditor?: boolean;
30
+ autoCrop?: boolean;
29
31
  onCropChange?: (rectangle: Rectangle) => void;
30
32
  }
31
33
 
@@ -48,6 +50,8 @@ export const CropEditor: React.FC<CropEditorProps> = ({
48
50
  overlayStrokeColor = '#e7a649',
49
51
  handlerColor = '#e7a649',
50
52
  enablePanStrict = false,
53
+ enableEditor = false,
54
+ autoCrop = true,
51
55
  onCropChange,
52
56
  }) => {
53
57
  const [imageSize, setImageSize] = useState<{ width: number; height: number } | null>(null);
@@ -59,38 +63,6 @@ export const CropEditor: React.FC<CropEditorProps> = ({
59
63
  const [loadError, setLoadError] = useState<string | null>(null);
60
64
  const [croppedImageUri, setCroppedImageUri] = useState<string | null>(null);
61
65
 
62
- useEffect(() => {
63
- console.log('[CropEditor] Document path:', document.path);
64
- console.log('[CropEditor] Document dimensions:', document.width, 'x', document.height);
65
- console.log('[CropEditor] Document quad:', document.quad);
66
- console.log('[CropEditor] Document rectangle:', document.rectangle);
67
-
68
- // Load image size using Image.getSize
69
- const imageUri = document.path.startsWith('file://') ? document.path : `file://${document.path}`;
70
- Image.getSize(
71
- imageUri,
72
- (width, height) => {
73
- console.log('[CropEditor] Image.getSize success:', { width, height });
74
- setImageSize({ width, height });
75
-
76
- // If we have a rectangle (from auto-capture), crop the image
77
- if (document.rectangle || document.quad) {
78
- cropImageToRectangle(imageUri, width, height);
79
- } else {
80
- setIsImageLoading(false);
81
- setLoadError(null);
82
- }
83
- },
84
- (error) => {
85
- console.error('[CropEditor] Image.getSize error:', error);
86
- // Fallback to document dimensions
87
- console.log('[CropEditor] Using fallback dimensions:', document.width, 'x', document.height);
88
- setImageSize({ width: document.width, height: document.height });
89
- setIsImageLoading(false);
90
- }
91
- );
92
- }, [document]);
93
-
94
66
  const cropImageToRectangle = useCallback((imageUri: string, width: number, height: number) => {
95
67
  const cropManager = NativeModules.CustomCropManager as CustomCropManagerType | undefined;
96
68
 
@@ -145,6 +117,40 @@ export const CropEditor: React.FC<CropEditorProps> = ({
145
117
  );
146
118
  }, [document]);
147
119
 
120
+ useEffect(() => {
121
+ console.log('[CropEditor] Document path:', document.path);
122
+ console.log('[CropEditor] Document dimensions:', document.width, 'x', document.height);
123
+ console.log('[CropEditor] Document quad:', document.quad);
124
+ console.log('[CropEditor] Document rectangle:', document.rectangle);
125
+
126
+ const shouldAutoCrop = autoCrop && !enableEditor;
127
+
128
+ // Load image size using Image.getSize
129
+ const imageUri = document.path.startsWith('file://') ? document.path : `file://${document.path}`;
130
+ Image.getSize(
131
+ imageUri,
132
+ (width, height) => {
133
+ console.log('[CropEditor] Image.getSize success:', { width, height });
134
+ setImageSize({ width, height });
135
+
136
+ // If we have a rectangle (from auto-capture), crop the image
137
+ if (shouldAutoCrop && (document.rectangle || document.quad)) {
138
+ cropImageToRectangle(imageUri, width, height);
139
+ } else {
140
+ setIsImageLoading(false);
141
+ setLoadError(null);
142
+ }
143
+ },
144
+ (error) => {
145
+ console.error('[CropEditor] Image.getSize error:', error);
146
+ // Fallback to document dimensions
147
+ console.log('[CropEditor] Using fallback dimensions:', document.width, 'x', document.height);
148
+ setImageSize({ width: document.width, height: document.height });
149
+ setIsImageLoading(false);
150
+ }
151
+ );
152
+ }, [autoCrop, cropImageToRectangle, document, enableEditor]);
153
+
148
154
  // Get initial rectangle from detected quad or use default
149
155
  const getInitialRectangle = useCallback((): CropperRectangle | undefined => {
150
156
  if (!imageSize) {
@@ -226,6 +232,18 @@ export const CropEditor: React.FC<CropEditorProps> = ({
226
232
  <ActivityIndicator size="large" color={handlerColor} />
227
233
  <Text style={styles.loadingText}>Loading image...</Text>
228
234
  </View>
235
+ ) : enableEditor ? (
236
+ <CustomImageCropper
237
+ height={displaySize.height}
238
+ width={displaySize.width}
239
+ image={imageUri}
240
+ rectangleCoordinates={initialRect}
241
+ overlayColor={overlayColor}
242
+ overlayStrokeColor={overlayStrokeColor}
243
+ handlerColor={handlerColor}
244
+ enablePanStrict={enablePanStrict}
245
+ onDragEnd={handleDragEnd}
246
+ />
229
247
  ) : (
230
248
  <>
231
249
  {/* Show cropped image if available, otherwise show original */}
@@ -236,18 +254,6 @@ export const CropEditor: React.FC<CropEditorProps> = ({
236
254
  onLoad={() => console.log('[CropEditor] Image loaded successfully', croppedImageUri ? 'cropped' : 'original')}
237
255
  onError={(e) => console.error('[CropEditor] Image load error:', e.nativeEvent.error)}
238
256
  />
239
- {/* Temporarily disabled CustomImageCropper - showing image only */}
240
- {/* <CustomImageCropper
241
- height={displaySize.height}
242
- width={displaySize.width}
243
- image={imageUri}
244
- rectangleCoordinates={initialRect}
245
- overlayColor={overlayColor}
246
- overlayStrokeColor={overlayStrokeColor}
247
- handlerColor={handlerColor}
248
- enablePanStrict={enablePanStrict}
249
- onDragEnd={handleDragEnd}
250
- /> */}
251
257
  </>
252
258
  )}
253
259
  </View>
@@ -16,7 +16,8 @@ import { launchImageLibrary } from 'react-native-image-picker';
16
16
  import ImageCropPicker from 'react-native-image-crop-picker';
17
17
  import RNFS from 'react-native-fs';
18
18
  import { DocScanner } from './DocScanner';
19
- import type { CapturedDocument } from './types';
19
+ import type { CapturedDocument, Rectangle } from './types';
20
+ import { quadToRectangle } from './utils/coordinate';
20
21
  import type {
21
22
  DetectionConfig,
22
23
  DocScannerHandle,
@@ -41,6 +42,25 @@ const ensureFileUri = (value?: string | null) => {
41
42
  return value;
42
43
  };
43
44
 
45
+ const safeRequire = (moduleName: string) => {
46
+ try {
47
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
48
+ return require(moduleName);
49
+ } catch {
50
+ return null;
51
+ }
52
+ };
53
+
54
+ const CropEditorModule = safeRequire('./CropEditor');
55
+ const CropEditor = CropEditorModule?.CropEditor as
56
+ | React.ComponentType<{
57
+ document: CapturedDocument;
58
+ enableEditor?: boolean;
59
+ autoCrop?: boolean;
60
+ onCropChange?: (rectangle: Rectangle) => void;
61
+ }>
62
+ | undefined;
63
+
44
64
  const CROPPER_TIMEOUT_MS = 8000;
45
65
  const CROPPER_TIMEOUT_CODE = 'cropper_timeout';
46
66
 
@@ -198,6 +218,8 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
198
218
  const [capturedPhotos, setCapturedPhotos] = useState<FullDocScannerResult[]>([]);
199
219
  const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
200
220
  const [scannerSession, setScannerSession] = useState(0);
221
+ const [cropEditorDocument, setCropEditorDocument] = useState<CapturedDocument | null>(null);
222
+ const [cropEditorRectangle, setCropEditorRectangle] = useState<Rectangle | null>(null);
201
223
  const resolvedGridColor = gridColor ?? overlayColor;
202
224
  const docScannerRef = useRef<DocScannerHandle | null>(null);
203
225
  const captureModeRef = useRef<'grid' | 'no-grid' | null>(null);
@@ -212,6 +234,8 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
212
234
  (options?: { remount?: boolean }) => {
213
235
  setProcessing(false);
214
236
  setCroppedImageData(null);
237
+ setCropEditorDocument(null);
238
+ setCropEditorRectangle(null);
215
239
  setRotationDegrees(0);
216
240
  setRectangleDetected(false);
217
241
  setRectangleHint(false);
@@ -264,6 +288,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
264
288
  );
265
289
 
266
290
  const pdfScannerManager = (NativeModules as any)?.RNPdfScannerManager;
291
+ const isAndroidCropEditorAvailable = Platform.OS === 'android' && Boolean(CropEditor);
267
292
 
268
293
  const autoEnhancementEnabled = useMemo(
269
294
  () => typeof pdfScannerManager?.applyColorControls === 'function',
@@ -353,6 +378,20 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
353
378
  [onError],
354
379
  );
355
380
 
381
+ const openAndroidCropEditor = useCallback((document: CapturedDocument) => {
382
+ const rectangle =
383
+ document.rectangle ??
384
+ (document.quad && document.quad.length === 4 ? quadToRectangle(document.quad) : null);
385
+ const documentForEditor = rectangle ? { ...document, rectangle } : document;
386
+ setCropEditorDocument(documentForEditor);
387
+ setCropEditorRectangle(rectangle);
388
+ }, []);
389
+
390
+ const closeAndroidCropEditor = useCallback(() => {
391
+ setCropEditorDocument(null);
392
+ setCropEditorRectangle(null);
393
+ }, []);
394
+
356
395
  const openCropper = useCallback(
357
396
  async (imagePath: string, options?: OpenCropperOptions) => {
358
397
  try {
@@ -446,6 +485,76 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
446
485
  [cropWidth, cropHeight, emitError, preparePreviewImage, resetScannerView],
447
486
  );
448
487
 
488
+ const handleCropEditorConfirm = useCallback(async () => {
489
+ if (!cropEditorDocument) {
490
+ return;
491
+ }
492
+
493
+ const documentPath = cropEditorDocument.path;
494
+
495
+ const rectangle =
496
+ cropEditorRectangle ??
497
+ cropEditorDocument.rectangle ??
498
+ (cropEditorDocument.quad && cropEditorDocument.quad.length === 4
499
+ ? quadToRectangle(cropEditorDocument.quad)
500
+ : null);
501
+
502
+ if (!rectangle || !pdfScannerManager?.processImage) {
503
+ closeAndroidCropEditor();
504
+ await openCropper(documentPath, { waitForPickerDismissal: false });
505
+ return;
506
+ }
507
+
508
+ setProcessing(true);
509
+ try {
510
+ const payload = await pdfScannerManager.processImage({
511
+ imagePath: documentPath,
512
+ rectangleCoordinates: rectangle,
513
+ rectangleWidth: cropEditorDocument.width ?? 0,
514
+ rectangleHeight: cropEditorDocument.height ?? 0,
515
+ useBase64: false,
516
+ quality: 90,
517
+ brightness: 0,
518
+ contrast: 1,
519
+ saturation: 1,
520
+ saveInAppDocument: false,
521
+ });
522
+
523
+ const croppedPath =
524
+ typeof payload?.croppedImage === 'string' ? stripFileUri(payload.croppedImage) : null;
525
+
526
+ if (!croppedPath) {
527
+ throw new Error('missing_cropped_image');
528
+ }
529
+
530
+ const preview = await preparePreviewImage({ path: croppedPath });
531
+ setCroppedImageData(preview);
532
+ } catch (error) {
533
+ console.error('[FullDocScanner] Crop editor processing failed:', error);
534
+ resetScannerView({ remount: true });
535
+ emitError(
536
+ error instanceof Error ? error : new Error(String(error)),
537
+ 'Failed to crop image. Please try again.',
538
+ );
539
+ } finally {
540
+ setProcessing(false);
541
+ closeAndroidCropEditor();
542
+ }
543
+ }, [
544
+ closeAndroidCropEditor,
545
+ cropEditorDocument,
546
+ cropEditorRectangle,
547
+ emitError,
548
+ openCropper,
549
+ pdfScannerManager,
550
+ preparePreviewImage,
551
+ resetScannerView,
552
+ ]);
553
+
554
+ const handleCropEditorCancel = useCallback(() => {
555
+ resetScannerView({ remount: true });
556
+ }, [resetScannerView]);
557
+
449
558
  const handleCapture = useCallback(
450
559
  async (document: DocScannerCapture) => {
451
560
  console.log('[FullDocScanner] handleCapture called:', {
@@ -470,6 +579,20 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
470
579
 
471
580
  const normalizedDoc = normalizeCapturedDocument(document);
472
581
 
582
+ const shouldOpenAndroidCropEditor =
583
+ isAndroidCropEditorAvailable &&
584
+ captureMode === 'grid' &&
585
+ Boolean(
586
+ normalizedDoc.rectangle ||
587
+ (normalizedDoc.quad && normalizedDoc.quad.length === 4),
588
+ );
589
+
590
+ if (shouldOpenAndroidCropEditor) {
591
+ console.log('[FullDocScanner] Opening Android crop editor with detected rectangle');
592
+ openAndroidCropEditor(normalizedDoc);
593
+ return;
594
+ }
595
+
473
596
  if (captureMode === 'no-grid') {
474
597
  console.log('[FullDocScanner] No grid at capture button press: opening cropper for manual selection');
475
598
  await openCropper(normalizedDoc.path, { waitForPickerDismissal: false });
@@ -501,7 +624,14 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
501
624
  console.log('[FullDocScanner] Fallback to manual crop (no croppedPath available)');
502
625
  await openCropper(normalizedDoc.path, { waitForPickerDismissal: false });
503
626
  },
504
- [emitError, openCropper, preparePreviewImage, resetScannerView],
627
+ [
628
+ emitError,
629
+ isAndroidCropEditorAvailable,
630
+ openAndroidCropEditor,
631
+ openCropper,
632
+ preparePreviewImage,
633
+ resetScannerView,
634
+ ],
505
635
  );
506
636
 
507
637
  const triggerManualCapture = useCallback(() => {
@@ -949,6 +1079,36 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
949
1079
  </TouchableOpacity>
950
1080
  </View>
951
1081
  </View>
1082
+ ) : cropEditorDocument && CropEditor ? (
1083
+ <View style={styles.flex}>
1084
+ <CropEditor
1085
+ document={cropEditorDocument}
1086
+ enableEditor
1087
+ autoCrop={false}
1088
+ onCropChange={setCropEditorRectangle}
1089
+ />
1090
+ <View style={styles.confirmationButtons}>
1091
+ <TouchableOpacity
1092
+ style={[styles.confirmButton, styles.retakeButton]}
1093
+ onPress={handleCropEditorCancel}
1094
+ accessibilityLabel={mergedStrings.retake}
1095
+ accessibilityRole="button"
1096
+ disabled={processing}
1097
+ >
1098
+ <Text style={styles.confirmButtonText}>{mergedStrings.retake}</Text>
1099
+ </TouchableOpacity>
1100
+
1101
+ <TouchableOpacity
1102
+ style={[styles.confirmButton, styles.confirmButtonPrimary]}
1103
+ onPress={handleCropEditorConfirm}
1104
+ accessibilityLabel={mergedStrings.confirm}
1105
+ accessibilityRole="button"
1106
+ disabled={processing}
1107
+ >
1108
+ <Text style={styles.confirmButtonText}>{mergedStrings.confirm}</Text>
1109
+ </TouchableOpacity>
1110
+ </View>
1111
+ </View>
952
1112
  ) : (
953
1113
  <View style={styles.flex}>
954
1114
  <DocScanner