react-native-rectangle-doc-scanner 13.9.0 → 15.0.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.
- package/android/build.gradle +2 -0
- package/android/src/main/kotlin/com/reactnativerectangledocscanner/DocumentScannerModule.kt +161 -1
- package/dist/CropEditor.d.ts +2 -0
- package/dist/CropEditor.js +33 -28
- package/dist/DocScanner.d.ts +5 -0
- package/dist/DocScanner.js +93 -1
- package/dist/FullDocScanner.js +125 -10
- package/package.json +1 -1
- package/src/CropEditor.tsx +50 -44
- package/src/DocScanner.tsx +135 -1
- package/src/FullDocScanner.tsx +180 -12
package/android/build.gradle
CHANGED
|
@@ -86,6 +86,8 @@ dependencies {
|
|
|
86
86
|
|
|
87
87
|
// ML Kit object detection for live rectangle hints (Camera2 mode)
|
|
88
88
|
implementation 'com.google.mlkit:object-detection:17.0.1'
|
|
89
|
+
// ML Kit document scanner (Activity-based flow)
|
|
90
|
+
implementation 'com.google.android.gms:play-services-mlkit-document-scanner:16.0.0'
|
|
89
91
|
|
|
90
92
|
if (hasVisionCamera) {
|
|
91
93
|
// VisionCamera mode - include VisionCamera dependency
|
|
@@ -1,16 +1,32 @@
|
|
|
1
1
|
package com.reactnativerectangledocscanner
|
|
2
2
|
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.content.Intent
|
|
3
5
|
import android.graphics.BitmapFactory
|
|
6
|
+
import android.net.Uri
|
|
4
7
|
import android.util.Log
|
|
5
8
|
import com.facebook.react.bridge.*
|
|
6
9
|
import com.facebook.react.uimanager.UIManagerModule
|
|
10
|
+
import com.google.mlkit.vision.documentscanner.GmsDocumentScannerOptions
|
|
11
|
+
import com.google.mlkit.vision.documentscanner.GmsDocumentScanning
|
|
12
|
+
import com.google.mlkit.vision.documentscanner.GmsDocumentScanningResult
|
|
7
13
|
import kotlinx.coroutines.*
|
|
8
14
|
import org.opencv.core.Point
|
|
15
|
+
import java.io.File
|
|
16
|
+
import java.io.FileOutputStream
|
|
17
|
+
import java.io.InputStream
|
|
9
18
|
|
|
10
19
|
class DocumentScannerModule(reactContext: ReactApplicationContext) :
|
|
11
|
-
ReactContextBaseJavaModule(reactContext) {
|
|
20
|
+
ReactContextBaseJavaModule(reactContext), ActivityEventListener {
|
|
12
21
|
|
|
13
22
|
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
|
23
|
+
private var pendingScanPromise: Promise? = null
|
|
24
|
+
|
|
25
|
+
private val scanRequestCode = 39201
|
|
26
|
+
|
|
27
|
+
init {
|
|
28
|
+
reactContext.addActivityEventListener(this)
|
|
29
|
+
}
|
|
14
30
|
|
|
15
31
|
companion object {
|
|
16
32
|
const val NAME = "RNPdfScannerManager"
|
|
@@ -19,6 +35,55 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) :
|
|
|
19
35
|
|
|
20
36
|
override fun getName() = NAME
|
|
21
37
|
|
|
38
|
+
@ReactMethod
|
|
39
|
+
fun startDocumentScanner(options: ReadableMap?, promise: Promise) {
|
|
40
|
+
val activity = currentActivity
|
|
41
|
+
if (activity == null) {
|
|
42
|
+
promise.reject("NO_ACTIVITY", "Activity doesn't exist")
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (pendingScanPromise != null) {
|
|
47
|
+
promise.reject("SCAN_IN_PROGRESS", "Document scanner already in progress")
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
val pageLimit = options?.takeIf { it.hasKey("pageLimit") }?.getInt("pageLimit") ?: 2
|
|
52
|
+
|
|
53
|
+
val scannerOptions = GmsDocumentScannerOptions.Builder()
|
|
54
|
+
.setScannerMode(GmsDocumentScannerOptions.SCANNER_MODE_FULL)
|
|
55
|
+
.setResultFormats(GmsDocumentScannerOptions.RESULT_FORMAT_JPEG)
|
|
56
|
+
.setPageLimit(pageLimit.coerceAtMost(2))
|
|
57
|
+
.setGalleryImportAllowed(false)
|
|
58
|
+
.build()
|
|
59
|
+
|
|
60
|
+
val scanner = GmsDocumentScanning.getClient(scannerOptions)
|
|
61
|
+
pendingScanPromise = promise
|
|
62
|
+
|
|
63
|
+
scanner.getStartScanIntent(activity)
|
|
64
|
+
.addOnSuccessListener { intentSender ->
|
|
65
|
+
try {
|
|
66
|
+
activity.startIntentSenderForResult(
|
|
67
|
+
intentSender,
|
|
68
|
+
scanRequestCode,
|
|
69
|
+
null,
|
|
70
|
+
0,
|
|
71
|
+
0,
|
|
72
|
+
0
|
|
73
|
+
)
|
|
74
|
+
} catch (e: Exception) {
|
|
75
|
+
Log.e(TAG, "Failed to launch document scanner", e)
|
|
76
|
+
pendingScanPromise = null
|
|
77
|
+
promise.reject("SCAN_LAUNCH_FAILED", "Failed to launch scanner: ${e.message}", e)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
.addOnFailureListener { e ->
|
|
81
|
+
Log.e(TAG, "Failed to get document scanner intent", e)
|
|
82
|
+
pendingScanPromise = null
|
|
83
|
+
promise.reject("SCAN_INIT_FAILED", "Failed to initialize scanner: ${e.message}", e)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
22
87
|
/**
|
|
23
88
|
* Capture image from the document scanner view
|
|
24
89
|
* Matches iOS signature: capture(reactTag, resolver, rejecter)
|
|
@@ -238,6 +303,101 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) :
|
|
|
238
303
|
}
|
|
239
304
|
}
|
|
240
305
|
|
|
306
|
+
override fun onActivityResult(activity: Activity?, requestCode: Int, resultCode: Int, data: Intent?) {
|
|
307
|
+
if (requestCode != scanRequestCode) {
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
val promise = pendingScanPromise
|
|
312
|
+
pendingScanPromise = null
|
|
313
|
+
|
|
314
|
+
if (promise == null) {
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (resultCode != Activity.RESULT_OK) {
|
|
319
|
+
promise.reject("SCAN_CANCELLED", "Document scan cancelled")
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
val result = GmsDocumentScanningResult.fromActivityResultIntent(data)
|
|
324
|
+
if (result == null) {
|
|
325
|
+
promise.reject("SCAN_NO_RESULT", "No scan result returned")
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
val pages = result.pages
|
|
330
|
+
if (pages.isNullOrEmpty()) {
|
|
331
|
+
promise.reject("SCAN_NO_PAGES", "No pages returned from scanner")
|
|
332
|
+
return
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
val outputPages = Arguments.createArray()
|
|
337
|
+
var firstPath: String? = null
|
|
338
|
+
var firstWidth = 0
|
|
339
|
+
var firstHeight = 0
|
|
340
|
+
|
|
341
|
+
pages.forEachIndexed { index, page ->
|
|
342
|
+
val imageUri = page.imageUri
|
|
343
|
+
val outputFile = copyUriToCache(imageUri, index)
|
|
344
|
+
val (width, height) = readImageSize(outputFile.absolutePath)
|
|
345
|
+
|
|
346
|
+
if (index == 0) {
|
|
347
|
+
firstPath = outputFile.absolutePath
|
|
348
|
+
firstWidth = width
|
|
349
|
+
firstHeight = height
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
val pageMap = Arguments.createMap().apply {
|
|
353
|
+
putString("path", outputFile.absolutePath)
|
|
354
|
+
putInt("width", width)
|
|
355
|
+
putInt("height", height)
|
|
356
|
+
}
|
|
357
|
+
outputPages.pushMap(pageMap)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
val response = Arguments.createMap().apply {
|
|
361
|
+
putString("croppedImage", firstPath)
|
|
362
|
+
putString("initialImage", firstPath)
|
|
363
|
+
putInt("width", firstWidth)
|
|
364
|
+
putInt("height", firstHeight)
|
|
365
|
+
putArray("pages", outputPages)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
promise.resolve(response)
|
|
369
|
+
} catch (e: Exception) {
|
|
370
|
+
Log.e(TAG, "Failed to handle scan result", e)
|
|
371
|
+
promise.reject("SCAN_PROCESS_FAILED", "Failed to handle scan result: ${e.message}", e)
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
override fun onNewIntent(intent: Intent?) {
|
|
376
|
+
// No-op
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private fun copyUriToCache(uri: Uri, index: Int): File {
|
|
380
|
+
val filename = "docscanner_page_${System.currentTimeMillis()}_$index.jpg"
|
|
381
|
+
val outputFile = File(reactApplicationContext.cacheDir, filename)
|
|
382
|
+
val resolver = reactApplicationContext.contentResolver
|
|
383
|
+
val inputStream: InputStream = resolver.openInputStream(uri)
|
|
384
|
+
?: throw IllegalStateException("Failed to open input stream for $uri")
|
|
385
|
+
|
|
386
|
+
inputStream.use { input ->
|
|
387
|
+
FileOutputStream(outputFile).use { output ->
|
|
388
|
+
input.copyTo(output)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return outputFile
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private fun readImageSize(path: String): Pair<Int, Int> {
|
|
396
|
+
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
|
397
|
+
BitmapFactory.decodeFile(path, options)
|
|
398
|
+
return options.outWidth to options.outHeight
|
|
399
|
+
}
|
|
400
|
+
|
|
241
401
|
override fun onCatalystInstanceDestroy() {
|
|
242
402
|
super.onCatalystInstanceDestroy()
|
|
243
403
|
scope.cancel()
|
package/dist/CropEditor.d.ts
CHANGED
package/dist/CropEditor.js
CHANGED
|
@@ -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;
|
package/dist/DocScanner.d.ts
CHANGED
|
@@ -8,6 +8,11 @@ type PictureEvent = {
|
|
|
8
8
|
height?: number;
|
|
9
9
|
rectangleCoordinates?: NativeRectangle | null;
|
|
10
10
|
rectangleOnScreen?: NativeRectangle | null;
|
|
11
|
+
pages?: Array<{
|
|
12
|
+
path: string;
|
|
13
|
+
width: number;
|
|
14
|
+
height: number;
|
|
15
|
+
}> | null;
|
|
11
16
|
};
|
|
12
17
|
export type RectangleDetectEvent = Omit<RectangleEventPayload, 'rectangleCoordinates' | 'rectangleOnScreen'> & {
|
|
13
18
|
rectangleCoordinates?: Rectangle | null;
|
package/dist/DocScanner.js
CHANGED
|
@@ -44,6 +44,7 @@ const coordinate_1 = require("./utils/coordinate");
|
|
|
44
44
|
const overlay_1 = require("./utils/overlay");
|
|
45
45
|
const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
|
|
46
46
|
const { RNPdfScannerManager } = react_native_1.NativeModules;
|
|
47
|
+
const isAndroidDocumentScanner = react_native_1.Platform.OS === 'android' && typeof RNPdfScannerManager?.startDocumentScanner === 'function';
|
|
47
48
|
const safeRequire = (moduleName) => {
|
|
48
49
|
try {
|
|
49
50
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
@@ -87,6 +88,89 @@ const RectangleQuality = {
|
|
|
87
88
|
BAD_ANGLE: 1,
|
|
88
89
|
TOO_FAR: 2,
|
|
89
90
|
};
|
|
91
|
+
const DEFAULT_PAGE_LIMIT = 2;
|
|
92
|
+
const ActivityScanner = (0, react_1.forwardRef)(({ onCapture, children, showManualCaptureButton = false, }, ref) => {
|
|
93
|
+
const captureOriginRef = (0, react_1.useRef)('auto');
|
|
94
|
+
const captureResolvers = (0, react_1.useRef)(null);
|
|
95
|
+
const handlePictureTaken = (0, react_1.useCallback)((event) => {
|
|
96
|
+
const captureError = event?.error;
|
|
97
|
+
if (captureError) {
|
|
98
|
+
captureOriginRef.current = 'auto';
|
|
99
|
+
if (captureResolvers.current) {
|
|
100
|
+
captureResolvers.current.reject(new Error(String(captureError)));
|
|
101
|
+
captureResolvers.current = null;
|
|
102
|
+
}
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const normalizedRectangle = normalizeRectangle(event.rectangleCoordinates ?? null) ??
|
|
106
|
+
normalizeRectangle(event.rectangleOnScreen ?? null) ??
|
|
107
|
+
null;
|
|
108
|
+
const quad = normalizedRectangle ? (0, coordinate_1.rectangleToQuad)(normalizedRectangle) : null;
|
|
109
|
+
const origin = captureOriginRef.current;
|
|
110
|
+
captureOriginRef.current = 'auto';
|
|
111
|
+
const initialPath = event.initialImage ?? null;
|
|
112
|
+
const croppedPath = event.croppedImage ?? null;
|
|
113
|
+
const editablePath = initialPath ?? croppedPath;
|
|
114
|
+
if (editablePath) {
|
|
115
|
+
onCapture?.({
|
|
116
|
+
path: editablePath,
|
|
117
|
+
initialPath,
|
|
118
|
+
croppedPath,
|
|
119
|
+
quad,
|
|
120
|
+
rectangle: normalizedRectangle,
|
|
121
|
+
width: event.width ?? 0,
|
|
122
|
+
height: event.height ?? 0,
|
|
123
|
+
origin,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
if (captureResolvers.current) {
|
|
127
|
+
captureResolvers.current.resolve(event);
|
|
128
|
+
captureResolvers.current = null;
|
|
129
|
+
}
|
|
130
|
+
}, [onCapture]);
|
|
131
|
+
const handleError = (0, react_1.useCallback)((error) => {
|
|
132
|
+
if (captureResolvers.current) {
|
|
133
|
+
captureResolvers.current.reject(error);
|
|
134
|
+
captureResolvers.current = null;
|
|
135
|
+
}
|
|
136
|
+
}, []);
|
|
137
|
+
const capture = (0, react_1.useCallback)(() => {
|
|
138
|
+
captureOriginRef.current = 'manual';
|
|
139
|
+
if (!RNPdfScannerManager?.startDocumentScanner) {
|
|
140
|
+
captureOriginRef.current = 'auto';
|
|
141
|
+
return Promise.reject(new Error('document_scanner_not_available'));
|
|
142
|
+
}
|
|
143
|
+
if (captureResolvers.current) {
|
|
144
|
+
captureOriginRef.current = 'auto';
|
|
145
|
+
return Promise.reject(new Error('capture_in_progress'));
|
|
146
|
+
}
|
|
147
|
+
return new Promise((resolve, reject) => {
|
|
148
|
+
captureResolvers.current = { resolve, reject };
|
|
149
|
+
RNPdfScannerManager.startDocumentScanner({ pageLimit: DEFAULT_PAGE_LIMIT })
|
|
150
|
+
.then(handlePictureTaken)
|
|
151
|
+
.catch((error) => {
|
|
152
|
+
captureOriginRef.current = 'auto';
|
|
153
|
+
handleError(error);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
}, [handleError, handlePictureTaken]);
|
|
157
|
+
(0, react_1.useImperativeHandle)(ref, () => ({
|
|
158
|
+
capture,
|
|
159
|
+
reset: () => {
|
|
160
|
+
if (captureResolvers.current) {
|
|
161
|
+
captureResolvers.current.reject(new Error('reset'));
|
|
162
|
+
captureResolvers.current = null;
|
|
163
|
+
}
|
|
164
|
+
captureOriginRef.current = 'auto';
|
|
165
|
+
},
|
|
166
|
+
}), [capture]);
|
|
167
|
+
const handleManualCapture = (0, react_1.useCallback)(() => {
|
|
168
|
+
capture().catch(() => null);
|
|
169
|
+
}, [capture]);
|
|
170
|
+
return (react_1.default.createElement(react_native_1.View, { style: styles.container },
|
|
171
|
+
showManualCaptureButton && (react_1.default.createElement(react_native_1.TouchableOpacity, { style: styles.button, onPress: handleManualCapture })),
|
|
172
|
+
children));
|
|
173
|
+
});
|
|
90
174
|
const evaluateRectangleQualityInView = (rectangle, viewWidth, viewHeight) => {
|
|
91
175
|
if (!viewWidth || !viewHeight) {
|
|
92
176
|
return RectangleQuality.TOO_FAR;
|
|
@@ -725,9 +809,17 @@ exports.DocScanner = (0, react_1.forwardRef)((props, ref) => {
|
|
|
725
809
|
if (react_native_1.Platform.OS !== 'android') {
|
|
726
810
|
return;
|
|
727
811
|
}
|
|
728
|
-
|
|
812
|
+
if (isAndroidDocumentScanner) {
|
|
813
|
+
console.log('[DocScanner] Using ML Kit document scanner activity on Android');
|
|
814
|
+
}
|
|
815
|
+
else {
|
|
816
|
+
console.log('[DocScanner] Using native CameraX pipeline on Android');
|
|
817
|
+
}
|
|
729
818
|
}, []);
|
|
730
819
|
if (react_native_1.Platform.OS === 'android') {
|
|
820
|
+
if (isAndroidDocumentScanner) {
|
|
821
|
+
return react_1.default.createElement(ActivityScanner, { ref: ref, ...props });
|
|
822
|
+
}
|
|
731
823
|
return react_1.default.createElement(NativeScanner, { ref: ref, ...props });
|
|
732
824
|
}
|
|
733
825
|
if (hasVisionCamera) {
|
package/dist/FullDocScanner.js
CHANGED
|
@@ -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);
|
|
@@ -143,13 +157,18 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
143
157
|
const rectangleHintTimeoutRef = (0, react_1.useRef)(null);
|
|
144
158
|
const captureReadyTimeoutRef = (0, react_1.useRef)(null);
|
|
145
159
|
const isBusinessMode = type === 'business';
|
|
160
|
+
const pdfScannerManager = react_native_1.NativeModules?.RNPdfScannerManager;
|
|
161
|
+
const isAndroidCropEditorAvailable = react_native_1.Platform.OS === 'android' && Boolean(CropEditor);
|
|
162
|
+
const usesAndroidScannerActivity = react_native_1.Platform.OS === 'android' && typeof pdfScannerManager?.startDocumentScanner === 'function';
|
|
146
163
|
const resetScannerView = (0, react_1.useCallback)((options) => {
|
|
147
164
|
setProcessing(false);
|
|
148
165
|
setCroppedImageData(null);
|
|
166
|
+
setCropEditorDocument(null);
|
|
167
|
+
setCropEditorRectangle(null);
|
|
149
168
|
setRotationDegrees(0);
|
|
150
169
|
setRectangleDetected(false);
|
|
151
170
|
setRectangleHint(false);
|
|
152
|
-
setCaptureReady(false);
|
|
171
|
+
setCaptureReady(usesAndroidScannerActivity ? true : false);
|
|
153
172
|
captureModeRef.current = null;
|
|
154
173
|
captureInProgressRef.current = false;
|
|
155
174
|
if (rectangleCaptureTimeoutRef.current) {
|
|
@@ -170,7 +189,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
170
189
|
if (options?.remount) {
|
|
171
190
|
setScannerSession((prev) => prev + 1);
|
|
172
191
|
}
|
|
173
|
-
}, []);
|
|
192
|
+
}, [usesAndroidScannerActivity]);
|
|
174
193
|
const mergedStrings = (0, react_1.useMemo)(() => ({
|
|
175
194
|
captureHint: strings?.captureHint,
|
|
176
195
|
manualHint: strings?.manualHint,
|
|
@@ -186,7 +205,6 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
186
205
|
secondPrompt: strings?.secondPrompt ?? strings?.secondBtn ?? 'Capture Back Side?',
|
|
187
206
|
originalBtn: strings?.originalBtn ?? 'Use Original',
|
|
188
207
|
}), [strings]);
|
|
189
|
-
const pdfScannerManager = react_native_1.NativeModules?.RNPdfScannerManager;
|
|
190
208
|
const autoEnhancementEnabled = (0, react_1.useMemo)(() => typeof pdfScannerManager?.applyColorControls === 'function', [pdfScannerManager]);
|
|
191
209
|
const ensureBase64ForImage = (0, react_1.useCallback)(async (image) => {
|
|
192
210
|
if (image.base64) {
|
|
@@ -243,6 +261,17 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
243
261
|
react_native_1.Alert.alert('Document Scanner', fallbackMessage);
|
|
244
262
|
}
|
|
245
263
|
}, [onError]);
|
|
264
|
+
const openAndroidCropEditor = (0, react_1.useCallback)((document) => {
|
|
265
|
+
const rectangle = document.rectangle ??
|
|
266
|
+
(document.quad && document.quad.length === 4 ? (0, coordinate_1.quadToRectangle)(document.quad) : null);
|
|
267
|
+
const documentForEditor = rectangle ? { ...document, rectangle } : document;
|
|
268
|
+
setCropEditorDocument(documentForEditor);
|
|
269
|
+
setCropEditorRectangle(rectangle);
|
|
270
|
+
}, []);
|
|
271
|
+
const closeAndroidCropEditor = (0, react_1.useCallback)(() => {
|
|
272
|
+
setCropEditorDocument(null);
|
|
273
|
+
setCropEditorRectangle(null);
|
|
274
|
+
}, []);
|
|
246
275
|
const openCropper = (0, react_1.useCallback)(async (imagePath, options) => {
|
|
247
276
|
try {
|
|
248
277
|
console.log('[FullDocScanner] openCropper called with path:', imagePath);
|
|
@@ -314,6 +343,64 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
314
343
|
}
|
|
315
344
|
}
|
|
316
345
|
}, [cropWidth, cropHeight, emitError, preparePreviewImage, resetScannerView]);
|
|
346
|
+
const handleCropEditorConfirm = (0, react_1.useCallback)(async () => {
|
|
347
|
+
if (!cropEditorDocument) {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const documentPath = cropEditorDocument.path;
|
|
351
|
+
const rectangle = cropEditorRectangle ??
|
|
352
|
+
cropEditorDocument.rectangle ??
|
|
353
|
+
(cropEditorDocument.quad && cropEditorDocument.quad.length === 4
|
|
354
|
+
? (0, coordinate_1.quadToRectangle)(cropEditorDocument.quad)
|
|
355
|
+
: null);
|
|
356
|
+
if (!rectangle || !pdfScannerManager?.processImage) {
|
|
357
|
+
closeAndroidCropEditor();
|
|
358
|
+
await openCropper(documentPath, { waitForPickerDismissal: false });
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
setProcessing(true);
|
|
362
|
+
try {
|
|
363
|
+
const payload = await pdfScannerManager.processImage({
|
|
364
|
+
imagePath: documentPath,
|
|
365
|
+
rectangleCoordinates: rectangle,
|
|
366
|
+
rectangleWidth: cropEditorDocument.width ?? 0,
|
|
367
|
+
rectangleHeight: cropEditorDocument.height ?? 0,
|
|
368
|
+
useBase64: false,
|
|
369
|
+
quality: 90,
|
|
370
|
+
brightness: 0,
|
|
371
|
+
contrast: 1,
|
|
372
|
+
saturation: 1,
|
|
373
|
+
saveInAppDocument: false,
|
|
374
|
+
});
|
|
375
|
+
const croppedPath = typeof payload?.croppedImage === 'string' ? stripFileUri(payload.croppedImage) : null;
|
|
376
|
+
if (!croppedPath) {
|
|
377
|
+
throw new Error('missing_cropped_image');
|
|
378
|
+
}
|
|
379
|
+
const preview = await preparePreviewImage({ path: croppedPath });
|
|
380
|
+
setCroppedImageData(preview);
|
|
381
|
+
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
console.error('[FullDocScanner] Crop editor processing failed:', error);
|
|
384
|
+
resetScannerView({ remount: true });
|
|
385
|
+
emitError(error instanceof Error ? error : new Error(String(error)), 'Failed to crop image. Please try again.');
|
|
386
|
+
}
|
|
387
|
+
finally {
|
|
388
|
+
setProcessing(false);
|
|
389
|
+
closeAndroidCropEditor();
|
|
390
|
+
}
|
|
391
|
+
}, [
|
|
392
|
+
closeAndroidCropEditor,
|
|
393
|
+
cropEditorDocument,
|
|
394
|
+
cropEditorRectangle,
|
|
395
|
+
emitError,
|
|
396
|
+
openCropper,
|
|
397
|
+
pdfScannerManager,
|
|
398
|
+
preparePreviewImage,
|
|
399
|
+
resetScannerView,
|
|
400
|
+
]);
|
|
401
|
+
const handleCropEditorCancel = (0, react_1.useCallback)(() => {
|
|
402
|
+
resetScannerView({ remount: true });
|
|
403
|
+
}, [resetScannerView]);
|
|
317
404
|
const handleCapture = (0, react_1.useCallback)(async (document) => {
|
|
318
405
|
console.log('[FullDocScanner] handleCapture called:', {
|
|
319
406
|
origin: document.origin,
|
|
@@ -332,6 +419,15 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
332
419
|
return;
|
|
333
420
|
}
|
|
334
421
|
const normalizedDoc = normalizeCapturedDocument(document);
|
|
422
|
+
const shouldOpenAndroidCropEditor = isAndroidCropEditorAvailable &&
|
|
423
|
+
captureMode === 'grid' &&
|
|
424
|
+
Boolean(normalizedDoc.rectangle ||
|
|
425
|
+
(normalizedDoc.quad && normalizedDoc.quad.length === 4));
|
|
426
|
+
if (shouldOpenAndroidCropEditor) {
|
|
427
|
+
console.log('[FullDocScanner] Opening Android crop editor with detected rectangle');
|
|
428
|
+
openAndroidCropEditor(normalizedDoc);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
335
431
|
if (captureMode === 'no-grid') {
|
|
336
432
|
console.log('[FullDocScanner] No grid at capture button press: opening cropper for manual selection');
|
|
337
433
|
await openCropper(normalizedDoc.path, { waitForPickerDismissal: false });
|
|
@@ -358,7 +454,14 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
358
454
|
}
|
|
359
455
|
console.log('[FullDocScanner] Fallback to manual crop (no croppedPath available)');
|
|
360
456
|
await openCropper(normalizedDoc.path, { waitForPickerDismissal: false });
|
|
361
|
-
}, [
|
|
457
|
+
}, [
|
|
458
|
+
emitError,
|
|
459
|
+
isAndroidCropEditorAvailable,
|
|
460
|
+
openAndroidCropEditor,
|
|
461
|
+
openCropper,
|
|
462
|
+
preparePreviewImage,
|
|
463
|
+
resetScannerView,
|
|
464
|
+
]);
|
|
362
465
|
const triggerManualCapture = (0, react_1.useCallback)(() => {
|
|
363
466
|
const scannerInstance = docScannerRef.current;
|
|
364
467
|
const hasScanner = !!scannerInstance;
|
|
@@ -371,7 +474,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
371
474
|
currentCaptureMode: captureModeRef.current,
|
|
372
475
|
captureInProgress: captureInProgressRef.current,
|
|
373
476
|
});
|
|
374
|
-
if (react_native_1.Platform.OS === 'android' && !captureReady) {
|
|
477
|
+
if (react_native_1.Platform.OS === 'android' && !captureReady && !usesAndroidScannerActivity) {
|
|
375
478
|
console.log('[FullDocScanner] Capture not ready yet, skipping');
|
|
376
479
|
return;
|
|
377
480
|
}
|
|
@@ -388,7 +491,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
388
491
|
return;
|
|
389
492
|
}
|
|
390
493
|
console.log('[FullDocScanner] Starting manual capture, grid detected:', rectangleDetected);
|
|
391
|
-
const captureMode = rectangleDetected ? 'grid' : 'no-grid';
|
|
494
|
+
const captureMode = usesAndroidScannerActivity ? 'grid' : rectangleDetected ? 'grid' : 'no-grid';
|
|
392
495
|
captureModeRef.current = captureMode;
|
|
393
496
|
captureInProgressRef.current = true;
|
|
394
497
|
// Add timeout to reset state if capture hangs
|
|
@@ -420,7 +523,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
420
523
|
emitError(error, 'Failed to capture image. Please try again.');
|
|
421
524
|
}
|
|
422
525
|
});
|
|
423
|
-
}, [processing, rectangleDetected, rectangleHint, captureReady, emitError]);
|
|
526
|
+
}, [processing, rectangleDetected, rectangleHint, captureReady, emitError, usesAndroidScannerActivity]);
|
|
424
527
|
const handleGalleryPick = (0, react_1.useCallback)(async () => {
|
|
425
528
|
console.log('[FullDocScanner] handleGalleryPick called');
|
|
426
529
|
if (processing || isGalleryOpen) {
|
|
@@ -601,6 +704,11 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
601
704
|
clearTimeout(captureReadyTimeoutRef.current);
|
|
602
705
|
}
|
|
603
706
|
}, []);
|
|
707
|
+
(0, react_1.useEffect)(() => {
|
|
708
|
+
if (usesAndroidScannerActivity) {
|
|
709
|
+
setCaptureReady(true);
|
|
710
|
+
}
|
|
711
|
+
}, [usesAndroidScannerActivity]);
|
|
604
712
|
const activePreviewImage = croppedImageData ? getActivePreviewImage(croppedImageData) : null;
|
|
605
713
|
return (react_1.default.createElement(react_native_1.View, { style: styles.container },
|
|
606
714
|
react_native_1.Platform.OS === 'android' && (react_1.default.createElement(react_native_1.StatusBar, { translucent: true, backgroundColor: "transparent" })),
|
|
@@ -649,6 +757,12 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
649
757
|
react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.confirmButton, styles.retakeButton], onPress: handleRetake, accessibilityLabel: mergedStrings.retake, accessibilityRole: "button" },
|
|
650
758
|
react_1.default.createElement(react_native_1.Text, { style: styles.confirmButtonText }, mergedStrings.retake)),
|
|
651
759
|
react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.confirmButton, styles.confirmButtonPrimary], onPress: handleConfirm, accessibilityLabel: mergedStrings.confirm, accessibilityRole: "button" },
|
|
760
|
+
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 },
|
|
761
|
+
react_1.default.createElement(CropEditor, { document: cropEditorDocument, enableEditor: true, autoCrop: false, onCropChange: setCropEditorRectangle }),
|
|
762
|
+
react_1.default.createElement(react_native_1.View, { style: styles.confirmationButtons },
|
|
763
|
+
react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.confirmButton, styles.retakeButton], onPress: handleCropEditorCancel, accessibilityLabel: mergedStrings.retake, accessibilityRole: "button", disabled: processing },
|
|
764
|
+
react_1.default.createElement(react_native_1.Text, { style: styles.confirmButtonText }, mergedStrings.retake)),
|
|
765
|
+
react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.confirmButton, styles.confirmButtonPrimary], onPress: handleCropEditorConfirm, accessibilityLabel: mergedStrings.confirm, accessibilityRole: "button", disabled: processing },
|
|
652
766
|
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
767
|
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
768
|
react_1.default.createElement(react_native_1.View, { style: styles.overlayTop, pointerEvents: "box-none" },
|
|
@@ -675,11 +789,12 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
675
789
|
react_1.default.createElement(react_native_1.TouchableOpacity, { style: [
|
|
676
790
|
styles.shutterButton,
|
|
677
791
|
processing && styles.buttonDisabled,
|
|
678
|
-
react_native_1.Platform.OS === 'android' && !captureReady && styles.buttonDisabled,
|
|
679
|
-
], onPress: triggerManualCapture, disabled: processing || (react_native_1.Platform.OS === 'android' && !captureReady), accessibilityLabel: mergedStrings.manualHint, accessibilityRole: "button" },
|
|
792
|
+
react_native_1.Platform.OS === 'android' && !captureReady && !usesAndroidScannerActivity && styles.buttonDisabled,
|
|
793
|
+
], onPress: triggerManualCapture, disabled: processing || (react_native_1.Platform.OS === 'android' && !captureReady && !usesAndroidScannerActivity), accessibilityLabel: mergedStrings.manualHint, accessibilityRole: "button" },
|
|
680
794
|
react_1.default.createElement(react_native_1.View, { style: [
|
|
681
795
|
styles.shutterInner,
|
|
682
|
-
(react_native_1.Platform.OS === 'android' ? captureReady : rectangleHint) &&
|
|
796
|
+
(react_native_1.Platform.OS === 'android' ? captureReady || usesAndroidScannerActivity : rectangleHint) &&
|
|
797
|
+
{ backgroundColor: overlayColor }
|
|
683
798
|
] })),
|
|
684
799
|
react_1.default.createElement(react_native_1.View, { style: styles.rightButtonsPlaceholder }))))),
|
|
685
800
|
processing && (react_1.default.createElement(react_native_1.View, { style: styles.processingOverlay },
|
package/package.json
CHANGED
package/src/CropEditor.tsx
CHANGED
|
@@ -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>
|
package/src/DocScanner.tsx
CHANGED
|
@@ -30,6 +30,7 @@ type PictureEvent = {
|
|
|
30
30
|
height?: number;
|
|
31
31
|
rectangleCoordinates?: NativeRectangle | null;
|
|
32
32
|
rectangleOnScreen?: NativeRectangle | null;
|
|
33
|
+
pages?: Array<{ path: string; width: number; height: number }> | null;
|
|
33
34
|
};
|
|
34
35
|
|
|
35
36
|
export type RectangleDetectEvent = Omit<RectangleEventPayload, 'rectangleCoordinates' | 'rectangleOnScreen'> & {
|
|
@@ -53,6 +54,8 @@ const isFiniteNumber = (value: unknown): value is number =>
|
|
|
53
54
|
typeof value === 'number' && Number.isFinite(value);
|
|
54
55
|
|
|
55
56
|
const { RNPdfScannerManager } = NativeModules;
|
|
57
|
+
const isAndroidDocumentScanner =
|
|
58
|
+
Platform.OS === 'android' && typeof RNPdfScannerManager?.startDocumentScanner === 'function';
|
|
56
59
|
|
|
57
60
|
const safeRequire = (moduleName: string) => {
|
|
58
61
|
try {
|
|
@@ -137,6 +140,130 @@ const RectangleQuality = {
|
|
|
137
140
|
TOO_FAR: 2,
|
|
138
141
|
} as const;
|
|
139
142
|
|
|
143
|
+
const DEFAULT_PAGE_LIMIT = 2;
|
|
144
|
+
|
|
145
|
+
const ActivityScanner = forwardRef<DocScannerHandle, Props>(
|
|
146
|
+
(
|
|
147
|
+
{
|
|
148
|
+
onCapture,
|
|
149
|
+
children,
|
|
150
|
+
showManualCaptureButton = false,
|
|
151
|
+
},
|
|
152
|
+
ref,
|
|
153
|
+
) => {
|
|
154
|
+
const captureOriginRef = useRef<'auto' | 'manual'>('auto');
|
|
155
|
+
const captureResolvers = useRef<{
|
|
156
|
+
resolve: (value: PictureEvent) => void;
|
|
157
|
+
reject: (error: Error) => void;
|
|
158
|
+
} | null>(null);
|
|
159
|
+
|
|
160
|
+
const handlePictureTaken = useCallback(
|
|
161
|
+
(event: PictureEvent) => {
|
|
162
|
+
const captureError = (event as any)?.error;
|
|
163
|
+
if (captureError) {
|
|
164
|
+
captureOriginRef.current = 'auto';
|
|
165
|
+
|
|
166
|
+
if (captureResolvers.current) {
|
|
167
|
+
captureResolvers.current.reject(new Error(String(captureError)));
|
|
168
|
+
captureResolvers.current = null;
|
|
169
|
+
}
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const normalizedRectangle =
|
|
174
|
+
normalizeRectangle(event.rectangleCoordinates ?? null) ??
|
|
175
|
+
normalizeRectangle(event.rectangleOnScreen ?? null) ??
|
|
176
|
+
null;
|
|
177
|
+
const quad = normalizedRectangle ? rectangleToQuad(normalizedRectangle) : null;
|
|
178
|
+
const origin = captureOriginRef.current;
|
|
179
|
+
captureOriginRef.current = 'auto';
|
|
180
|
+
|
|
181
|
+
const initialPath = event.initialImage ?? null;
|
|
182
|
+
const croppedPath = event.croppedImage ?? null;
|
|
183
|
+
const editablePath = initialPath ?? croppedPath;
|
|
184
|
+
|
|
185
|
+
if (editablePath) {
|
|
186
|
+
onCapture?.({
|
|
187
|
+
path: editablePath,
|
|
188
|
+
initialPath,
|
|
189
|
+
croppedPath,
|
|
190
|
+
quad,
|
|
191
|
+
rectangle: normalizedRectangle,
|
|
192
|
+
width: event.width ?? 0,
|
|
193
|
+
height: event.height ?? 0,
|
|
194
|
+
origin,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (captureResolvers.current) {
|
|
199
|
+
captureResolvers.current.resolve(event);
|
|
200
|
+
captureResolvers.current = null;
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
[onCapture],
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const handleError = useCallback((error: Error) => {
|
|
207
|
+
if (captureResolvers.current) {
|
|
208
|
+
captureResolvers.current.reject(error);
|
|
209
|
+
captureResolvers.current = null;
|
|
210
|
+
}
|
|
211
|
+
}, []);
|
|
212
|
+
|
|
213
|
+
const capture = useCallback((): Promise<PictureEvent> => {
|
|
214
|
+
captureOriginRef.current = 'manual';
|
|
215
|
+
|
|
216
|
+
if (!RNPdfScannerManager?.startDocumentScanner) {
|
|
217
|
+
captureOriginRef.current = 'auto';
|
|
218
|
+
return Promise.reject(new Error('document_scanner_not_available'));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (captureResolvers.current) {
|
|
222
|
+
captureOriginRef.current = 'auto';
|
|
223
|
+
return Promise.reject(new Error('capture_in_progress'));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return new Promise<PictureEvent>((resolve, reject) => {
|
|
227
|
+
captureResolvers.current = { resolve, reject };
|
|
228
|
+
RNPdfScannerManager.startDocumentScanner({ pageLimit: DEFAULT_PAGE_LIMIT })
|
|
229
|
+
.then(handlePictureTaken)
|
|
230
|
+
.catch((error: unknown) => {
|
|
231
|
+
captureOriginRef.current = 'auto';
|
|
232
|
+
handleError(error as Error);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
}, [handleError, handlePictureTaken]);
|
|
236
|
+
|
|
237
|
+
useImperativeHandle(
|
|
238
|
+
ref,
|
|
239
|
+
() => ({
|
|
240
|
+
capture,
|
|
241
|
+
reset: () => {
|
|
242
|
+
if (captureResolvers.current) {
|
|
243
|
+
captureResolvers.current.reject(new Error('reset'));
|
|
244
|
+
captureResolvers.current = null;
|
|
245
|
+
}
|
|
246
|
+
captureOriginRef.current = 'auto';
|
|
247
|
+
},
|
|
248
|
+
}),
|
|
249
|
+
[capture],
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const handleManualCapture = useCallback(() => {
|
|
253
|
+
capture().catch(() => null);
|
|
254
|
+
}, [capture]);
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
<View style={styles.container}>
|
|
258
|
+
{showManualCaptureButton && (
|
|
259
|
+
<TouchableOpacity style={styles.button} onPress={handleManualCapture} />
|
|
260
|
+
)}
|
|
261
|
+
{children}
|
|
262
|
+
</View>
|
|
263
|
+
);
|
|
264
|
+
},
|
|
265
|
+
);
|
|
266
|
+
|
|
140
267
|
const evaluateRectangleQualityInView = (rectangle: Rectangle, viewWidth: number, viewHeight: number) => {
|
|
141
268
|
if (!viewWidth || !viewHeight) {
|
|
142
269
|
return RectangleQuality.TOO_FAR;
|
|
@@ -1035,10 +1162,17 @@ export const DocScanner = forwardRef<DocScannerHandle, Props>((props, ref) => {
|
|
|
1035
1162
|
if (Platform.OS !== 'android') {
|
|
1036
1163
|
return;
|
|
1037
1164
|
}
|
|
1038
|
-
|
|
1165
|
+
if (isAndroidDocumentScanner) {
|
|
1166
|
+
console.log('[DocScanner] Using ML Kit document scanner activity on Android');
|
|
1167
|
+
} else {
|
|
1168
|
+
console.log('[DocScanner] Using native CameraX pipeline on Android');
|
|
1169
|
+
}
|
|
1039
1170
|
}, []);
|
|
1040
1171
|
|
|
1041
1172
|
if (Platform.OS === 'android') {
|
|
1173
|
+
if (isAndroidDocumentScanner) {
|
|
1174
|
+
return <ActivityScanner ref={ref} {...props} />;
|
|
1175
|
+
}
|
|
1042
1176
|
return <NativeScanner ref={ref} {...props} />;
|
|
1043
1177
|
}
|
|
1044
1178
|
|
package/src/FullDocScanner.tsx
CHANGED
|
@@ -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);
|
|
@@ -207,15 +229,21 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
207
229
|
const captureReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
208
230
|
|
|
209
231
|
const isBusinessMode = type === 'business';
|
|
232
|
+
const pdfScannerManager = (NativeModules as any)?.RNPdfScannerManager;
|
|
233
|
+
const isAndroidCropEditorAvailable = Platform.OS === 'android' && Boolean(CropEditor);
|
|
234
|
+
const usesAndroidScannerActivity =
|
|
235
|
+
Platform.OS === 'android' && typeof pdfScannerManager?.startDocumentScanner === 'function';
|
|
210
236
|
|
|
211
237
|
const resetScannerView = useCallback(
|
|
212
238
|
(options?: { remount?: boolean }) => {
|
|
213
239
|
setProcessing(false);
|
|
214
240
|
setCroppedImageData(null);
|
|
241
|
+
setCropEditorDocument(null);
|
|
242
|
+
setCropEditorRectangle(null);
|
|
215
243
|
setRotationDegrees(0);
|
|
216
244
|
setRectangleDetected(false);
|
|
217
245
|
setRectangleHint(false);
|
|
218
|
-
setCaptureReady(false);
|
|
246
|
+
setCaptureReady(usesAndroidScannerActivity ? true : false);
|
|
219
247
|
captureModeRef.current = null;
|
|
220
248
|
captureInProgressRef.current = false;
|
|
221
249
|
|
|
@@ -241,7 +269,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
241
269
|
setScannerSession((prev) => prev + 1);
|
|
242
270
|
}
|
|
243
271
|
},
|
|
244
|
-
[],
|
|
272
|
+
[usesAndroidScannerActivity],
|
|
245
273
|
);
|
|
246
274
|
|
|
247
275
|
const mergedStrings = useMemo(
|
|
@@ -263,8 +291,6 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
263
291
|
[strings],
|
|
264
292
|
);
|
|
265
293
|
|
|
266
|
-
const pdfScannerManager = (NativeModules as any)?.RNPdfScannerManager;
|
|
267
|
-
|
|
268
294
|
const autoEnhancementEnabled = useMemo(
|
|
269
295
|
() => typeof pdfScannerManager?.applyColorControls === 'function',
|
|
270
296
|
[pdfScannerManager],
|
|
@@ -353,6 +379,20 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
353
379
|
[onError],
|
|
354
380
|
);
|
|
355
381
|
|
|
382
|
+
const openAndroidCropEditor = useCallback((document: CapturedDocument) => {
|
|
383
|
+
const rectangle =
|
|
384
|
+
document.rectangle ??
|
|
385
|
+
(document.quad && document.quad.length === 4 ? quadToRectangle(document.quad) : null);
|
|
386
|
+
const documentForEditor = rectangle ? { ...document, rectangle } : document;
|
|
387
|
+
setCropEditorDocument(documentForEditor);
|
|
388
|
+
setCropEditorRectangle(rectangle);
|
|
389
|
+
}, []);
|
|
390
|
+
|
|
391
|
+
const closeAndroidCropEditor = useCallback(() => {
|
|
392
|
+
setCropEditorDocument(null);
|
|
393
|
+
setCropEditorRectangle(null);
|
|
394
|
+
}, []);
|
|
395
|
+
|
|
356
396
|
const openCropper = useCallback(
|
|
357
397
|
async (imagePath: string, options?: OpenCropperOptions) => {
|
|
358
398
|
try {
|
|
@@ -446,6 +486,76 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
446
486
|
[cropWidth, cropHeight, emitError, preparePreviewImage, resetScannerView],
|
|
447
487
|
);
|
|
448
488
|
|
|
489
|
+
const handleCropEditorConfirm = useCallback(async () => {
|
|
490
|
+
if (!cropEditorDocument) {
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const documentPath = cropEditorDocument.path;
|
|
495
|
+
|
|
496
|
+
const rectangle =
|
|
497
|
+
cropEditorRectangle ??
|
|
498
|
+
cropEditorDocument.rectangle ??
|
|
499
|
+
(cropEditorDocument.quad && cropEditorDocument.quad.length === 4
|
|
500
|
+
? quadToRectangle(cropEditorDocument.quad)
|
|
501
|
+
: null);
|
|
502
|
+
|
|
503
|
+
if (!rectangle || !pdfScannerManager?.processImage) {
|
|
504
|
+
closeAndroidCropEditor();
|
|
505
|
+
await openCropper(documentPath, { waitForPickerDismissal: false });
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
setProcessing(true);
|
|
510
|
+
try {
|
|
511
|
+
const payload = await pdfScannerManager.processImage({
|
|
512
|
+
imagePath: documentPath,
|
|
513
|
+
rectangleCoordinates: rectangle,
|
|
514
|
+
rectangleWidth: cropEditorDocument.width ?? 0,
|
|
515
|
+
rectangleHeight: cropEditorDocument.height ?? 0,
|
|
516
|
+
useBase64: false,
|
|
517
|
+
quality: 90,
|
|
518
|
+
brightness: 0,
|
|
519
|
+
contrast: 1,
|
|
520
|
+
saturation: 1,
|
|
521
|
+
saveInAppDocument: false,
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
const croppedPath =
|
|
525
|
+
typeof payload?.croppedImage === 'string' ? stripFileUri(payload.croppedImage) : null;
|
|
526
|
+
|
|
527
|
+
if (!croppedPath) {
|
|
528
|
+
throw new Error('missing_cropped_image');
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const preview = await preparePreviewImage({ path: croppedPath });
|
|
532
|
+
setCroppedImageData(preview);
|
|
533
|
+
} catch (error) {
|
|
534
|
+
console.error('[FullDocScanner] Crop editor processing failed:', error);
|
|
535
|
+
resetScannerView({ remount: true });
|
|
536
|
+
emitError(
|
|
537
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
538
|
+
'Failed to crop image. Please try again.',
|
|
539
|
+
);
|
|
540
|
+
} finally {
|
|
541
|
+
setProcessing(false);
|
|
542
|
+
closeAndroidCropEditor();
|
|
543
|
+
}
|
|
544
|
+
}, [
|
|
545
|
+
closeAndroidCropEditor,
|
|
546
|
+
cropEditorDocument,
|
|
547
|
+
cropEditorRectangle,
|
|
548
|
+
emitError,
|
|
549
|
+
openCropper,
|
|
550
|
+
pdfScannerManager,
|
|
551
|
+
preparePreviewImage,
|
|
552
|
+
resetScannerView,
|
|
553
|
+
]);
|
|
554
|
+
|
|
555
|
+
const handleCropEditorCancel = useCallback(() => {
|
|
556
|
+
resetScannerView({ remount: true });
|
|
557
|
+
}, [resetScannerView]);
|
|
558
|
+
|
|
449
559
|
const handleCapture = useCallback(
|
|
450
560
|
async (document: DocScannerCapture) => {
|
|
451
561
|
console.log('[FullDocScanner] handleCapture called:', {
|
|
@@ -470,6 +580,20 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
470
580
|
|
|
471
581
|
const normalizedDoc = normalizeCapturedDocument(document);
|
|
472
582
|
|
|
583
|
+
const shouldOpenAndroidCropEditor =
|
|
584
|
+
isAndroidCropEditorAvailable &&
|
|
585
|
+
captureMode === 'grid' &&
|
|
586
|
+
Boolean(
|
|
587
|
+
normalizedDoc.rectangle ||
|
|
588
|
+
(normalizedDoc.quad && normalizedDoc.quad.length === 4),
|
|
589
|
+
);
|
|
590
|
+
|
|
591
|
+
if (shouldOpenAndroidCropEditor) {
|
|
592
|
+
console.log('[FullDocScanner] Opening Android crop editor with detected rectangle');
|
|
593
|
+
openAndroidCropEditor(normalizedDoc);
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
|
|
473
597
|
if (captureMode === 'no-grid') {
|
|
474
598
|
console.log('[FullDocScanner] No grid at capture button press: opening cropper for manual selection');
|
|
475
599
|
await openCropper(normalizedDoc.path, { waitForPickerDismissal: false });
|
|
@@ -501,7 +625,14 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
501
625
|
console.log('[FullDocScanner] Fallback to manual crop (no croppedPath available)');
|
|
502
626
|
await openCropper(normalizedDoc.path, { waitForPickerDismissal: false });
|
|
503
627
|
},
|
|
504
|
-
[
|
|
628
|
+
[
|
|
629
|
+
emitError,
|
|
630
|
+
isAndroidCropEditorAvailable,
|
|
631
|
+
openAndroidCropEditor,
|
|
632
|
+
openCropper,
|
|
633
|
+
preparePreviewImage,
|
|
634
|
+
resetScannerView,
|
|
635
|
+
],
|
|
505
636
|
);
|
|
506
637
|
|
|
507
638
|
const triggerManualCapture = useCallback(() => {
|
|
@@ -517,7 +648,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
517
648
|
captureInProgress: captureInProgressRef.current,
|
|
518
649
|
});
|
|
519
650
|
|
|
520
|
-
if (Platform.OS === 'android' && !captureReady) {
|
|
651
|
+
if (Platform.OS === 'android' && !captureReady && !usesAndroidScannerActivity) {
|
|
521
652
|
console.log('[FullDocScanner] Capture not ready yet, skipping');
|
|
522
653
|
return;
|
|
523
654
|
}
|
|
@@ -539,7 +670,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
539
670
|
|
|
540
671
|
console.log('[FullDocScanner] Starting manual capture, grid detected:', rectangleDetected);
|
|
541
672
|
|
|
542
|
-
const captureMode = rectangleDetected ? 'grid' : 'no-grid';
|
|
673
|
+
const captureMode = usesAndroidScannerActivity ? 'grid' : rectangleDetected ? 'grid' : 'no-grid';
|
|
543
674
|
captureModeRef.current = captureMode;
|
|
544
675
|
captureInProgressRef.current = true;
|
|
545
676
|
|
|
@@ -580,7 +711,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
580
711
|
);
|
|
581
712
|
}
|
|
582
713
|
});
|
|
583
|
-
}, [processing, rectangleDetected, rectangleHint, captureReady, emitError]);
|
|
714
|
+
}, [processing, rectangleDetected, rectangleHint, captureReady, emitError, usesAndroidScannerActivity]);
|
|
584
715
|
|
|
585
716
|
const handleGalleryPick = useCallback(async () => {
|
|
586
717
|
console.log('[FullDocScanner] handleGalleryPick called');
|
|
@@ -804,6 +935,12 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
804
935
|
[],
|
|
805
936
|
);
|
|
806
937
|
|
|
938
|
+
useEffect(() => {
|
|
939
|
+
if (usesAndroidScannerActivity) {
|
|
940
|
+
setCaptureReady(true);
|
|
941
|
+
}
|
|
942
|
+
}, [usesAndroidScannerActivity]);
|
|
943
|
+
|
|
807
944
|
const activePreviewImage = croppedImageData ? getActivePreviewImage(croppedImageData) : null;
|
|
808
945
|
|
|
809
946
|
return (
|
|
@@ -949,6 +1086,36 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
949
1086
|
</TouchableOpacity>
|
|
950
1087
|
</View>
|
|
951
1088
|
</View>
|
|
1089
|
+
) : cropEditorDocument && CropEditor ? (
|
|
1090
|
+
<View style={styles.flex}>
|
|
1091
|
+
<CropEditor
|
|
1092
|
+
document={cropEditorDocument}
|
|
1093
|
+
enableEditor
|
|
1094
|
+
autoCrop={false}
|
|
1095
|
+
onCropChange={setCropEditorRectangle}
|
|
1096
|
+
/>
|
|
1097
|
+
<View style={styles.confirmationButtons}>
|
|
1098
|
+
<TouchableOpacity
|
|
1099
|
+
style={[styles.confirmButton, styles.retakeButton]}
|
|
1100
|
+
onPress={handleCropEditorCancel}
|
|
1101
|
+
accessibilityLabel={mergedStrings.retake}
|
|
1102
|
+
accessibilityRole="button"
|
|
1103
|
+
disabled={processing}
|
|
1104
|
+
>
|
|
1105
|
+
<Text style={styles.confirmButtonText}>{mergedStrings.retake}</Text>
|
|
1106
|
+
</TouchableOpacity>
|
|
1107
|
+
|
|
1108
|
+
<TouchableOpacity
|
|
1109
|
+
style={[styles.confirmButton, styles.confirmButtonPrimary]}
|
|
1110
|
+
onPress={handleCropEditorConfirm}
|
|
1111
|
+
accessibilityLabel={mergedStrings.confirm}
|
|
1112
|
+
accessibilityRole="button"
|
|
1113
|
+
disabled={processing}
|
|
1114
|
+
>
|
|
1115
|
+
<Text style={styles.confirmButtonText}>{mergedStrings.confirm}</Text>
|
|
1116
|
+
</TouchableOpacity>
|
|
1117
|
+
</View>
|
|
1118
|
+
</View>
|
|
952
1119
|
) : (
|
|
953
1120
|
<View style={styles.flex}>
|
|
954
1121
|
<DocScanner
|
|
@@ -1035,16 +1202,17 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
1035
1202
|
style={[
|
|
1036
1203
|
styles.shutterButton,
|
|
1037
1204
|
processing && styles.buttonDisabled,
|
|
1038
|
-
Platform.OS === 'android' && !captureReady && styles.buttonDisabled,
|
|
1205
|
+
Platform.OS === 'android' && !captureReady && !usesAndroidScannerActivity && styles.buttonDisabled,
|
|
1039
1206
|
]}
|
|
1040
1207
|
onPress={triggerManualCapture}
|
|
1041
|
-
disabled={processing || (Platform.OS === 'android' && !captureReady)}
|
|
1208
|
+
disabled={processing || (Platform.OS === 'android' && !captureReady && !usesAndroidScannerActivity)}
|
|
1042
1209
|
accessibilityLabel={mergedStrings.manualHint}
|
|
1043
1210
|
accessibilityRole="button"
|
|
1044
1211
|
>
|
|
1045
1212
|
<View style={[
|
|
1046
1213
|
styles.shutterInner,
|
|
1047
|
-
(Platform.OS === 'android' ? captureReady : rectangleHint) &&
|
|
1214
|
+
(Platform.OS === 'android' ? captureReady || usesAndroidScannerActivity : rectangleHint) &&
|
|
1215
|
+
{ backgroundColor: overlayColor }
|
|
1048
1216
|
]} />
|
|
1049
1217
|
</TouchableOpacity>
|
|
1050
1218
|
<View style={styles.rightButtonsPlaceholder} />
|