react-native-rectangle-doc-scanner 0.46.0 → 0.49.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.
@@ -285,9 +285,20 @@ const DocScanner = ({ onCapture, overlayColor = '#e7a649', autoCapture = true, m
285
285
  // Bilateral filter for edge-preserving smoothing (better quality than Gaussian)
286
286
  step = 'bilateralFilter';
287
287
  reportStage(step);
288
- const tempMat = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Mat);
289
- react_native_fast_opencv_1.OpenCV.invoke('bilateralFilter', mat, tempMat, 9, 75, 75);
290
- mat = tempMat;
288
+ try {
289
+ const tempMat = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Mat);
290
+ react_native_fast_opencv_1.OpenCV.invoke('bilateralFilter', mat, tempMat, 9, 75, 75);
291
+ mat = tempMat;
292
+ }
293
+ catch (error) {
294
+ if (__DEV__) {
295
+ console.warn('[DocScanner] bilateralFilter unavailable, falling back to GaussianBlur', error);
296
+ }
297
+ step = 'gaussianBlurFallback';
298
+ reportStage(step);
299
+ const blurKernel = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Size, 5, 5);
300
+ react_native_fast_opencv_1.OpenCV.invoke('GaussianBlur', mat, mat, blurKernel, 0);
301
+ }
291
302
  step = 'Canny';
292
303
  reportStage(step);
293
304
  // Configurable Canny parameters for adaptive edge detection
@@ -0,0 +1,31 @@
1
+ import React from 'react';
2
+ import type { CapturedDocument, Rectangle } from './types';
3
+ import type { DetectionConfig } from './DocScanner';
4
+ export interface FullDocScannerResult {
5
+ original: CapturedDocument;
6
+ rectangle: Rectangle | null;
7
+ /** Base64-encoded JPEG string returned by CustomCropManager */
8
+ base64: string;
9
+ }
10
+ export interface FullDocScannerStrings {
11
+ captureHint?: string;
12
+ manualHint?: string;
13
+ cancel?: string;
14
+ confirm?: string;
15
+ retake?: string;
16
+ cropTitle?: string;
17
+ processing?: string;
18
+ }
19
+ export interface FullDocScannerProps {
20
+ onResult: (result: FullDocScannerResult) => void;
21
+ onClose?: () => void;
22
+ detectionConfig?: DetectionConfig;
23
+ overlayColor?: string;
24
+ overlayStrokeColor?: string;
25
+ handlerColor?: string;
26
+ strings?: FullDocScannerStrings;
27
+ manualCapture?: boolean;
28
+ minStableFrames?: number;
29
+ onError?: (error: Error) => void;
30
+ }
31
+ export declare const FullDocScanner: React.FC<FullDocScannerProps>;
@@ -0,0 +1,257 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.FullDocScanner = void 0;
37
+ const react_1 = __importStar(require("react"));
38
+ const react_native_1 = require("react-native");
39
+ const DocScanner_1 = require("./DocScanner");
40
+ const CropEditor_1 = require("./CropEditor");
41
+ const coordinate_1 = require("./utils/coordinate");
42
+ const stripFileUri = (value) => value.replace(/^file:\/\//, '');
43
+ const ensureFileUri = (value) => (value.startsWith('file://') ? value : `file://${value}`);
44
+ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3170f3', overlayStrokeColor = '#3170f3', handlerColor = '#3170f3', strings, manualCapture = false, minStableFrames, onError, }) => {
45
+ const [screen, setScreen] = (0, react_1.useState)('scanner');
46
+ const [capturedDoc, setCapturedDoc] = (0, react_1.useState)(null);
47
+ const [cropRectangle, setCropRectangle] = (0, react_1.useState)(null);
48
+ const [imageSize, setImageSize] = (0, react_1.useState)(null);
49
+ const [processing, setProcessing] = (0, react_1.useState)(false);
50
+ const mergedStrings = (0, react_1.useMemo)(() => ({
51
+ captureHint: strings?.captureHint ?? 'Align the document within the frame.',
52
+ manualHint: strings?.manualHint ?? 'Tap the button below to capture.',
53
+ cancel: strings?.cancel ?? 'Cancel',
54
+ confirm: strings?.confirm ?? 'Use photo',
55
+ retake: strings?.retake ?? 'Retake',
56
+ cropTitle: strings?.cropTitle ?? 'Adjust the corners',
57
+ processing: strings?.processing ?? 'Processing…',
58
+ }), [strings]);
59
+ (0, react_1.useEffect)(() => {
60
+ if (!capturedDoc) {
61
+ return;
62
+ }
63
+ react_native_1.Image.getSize(ensureFileUri(capturedDoc.path), (width, height) => setImageSize({ width, height }), () => setImageSize({ width: capturedDoc.width, height: capturedDoc.height }));
64
+ }, [capturedDoc]);
65
+ const resetState = (0, react_1.useCallback)(() => {
66
+ setScreen('scanner');
67
+ setCapturedDoc(null);
68
+ setCropRectangle(null);
69
+ setImageSize(null);
70
+ setProcessing(false);
71
+ }, []);
72
+ const handleCapture = (0, react_1.useCallback)((document) => {
73
+ const normalizedPath = stripFileUri(document.path);
74
+ const nextQuad = document.quad && document.quad.length === 4 ? document.quad : null;
75
+ setCapturedDoc({
76
+ ...document,
77
+ path: normalizedPath,
78
+ quad: nextQuad,
79
+ });
80
+ setCropRectangle(nextQuad ? (0, coordinate_1.quadToRectangle)(nextQuad) : null);
81
+ setScreen('crop');
82
+ }, []);
83
+ const handleCropChange = (0, react_1.useCallback)((rectangle) => {
84
+ setCropRectangle(rectangle);
85
+ }, []);
86
+ const emitError = (0, react_1.useCallback)((error, fallbackMessage) => {
87
+ console.error('[FullDocScanner] error', error);
88
+ onError?.(error);
89
+ if (!onError && fallbackMessage) {
90
+ react_native_1.Alert.alert('Document Scanner', fallbackMessage);
91
+ }
92
+ }, [onError]);
93
+ const performCrop = (0, react_1.useCallback)(async () => {
94
+ if (!capturedDoc) {
95
+ throw new Error('No captured document to crop');
96
+ }
97
+ const size = imageSize ?? { width: capturedDoc.width, height: capturedDoc.height };
98
+ const cropManager = react_native_1.NativeModules.CustomCropManager;
99
+ if (!cropManager?.crop) {
100
+ throw new Error('CustomCropManager.crop is not available');
101
+ }
102
+ const fallbackRectangle = capturedDoc.quad && capturedDoc.quad.length === 4
103
+ ? (0, coordinate_1.quadToRectangle)(capturedDoc.quad)
104
+ : null;
105
+ const scaledFallback = fallbackRectangle
106
+ ? (0, coordinate_1.scaleRectangle)(fallbackRectangle, capturedDoc.width, capturedDoc.height, size.width, size.height)
107
+ : null;
108
+ const rectangle = cropRectangle ?? scaledFallback;
109
+ const base64 = await new Promise((resolve, reject) => {
110
+ cropManager.crop({
111
+ topLeft: rectangle?.topLeft ?? { x: 0, y: 0 },
112
+ topRight: rectangle?.topRight ?? { x: size.width, y: 0 },
113
+ bottomRight: rectangle?.bottomRight ?? { x: size.width, y: size.height },
114
+ bottomLeft: rectangle?.bottomLeft ?? { x: 0, y: size.height },
115
+ width: size.width,
116
+ height: size.height,
117
+ }, ensureFileUri(capturedDoc.path), (error, result) => {
118
+ if (error) {
119
+ reject(error instanceof Error ? error : new Error('Crop failed'));
120
+ return;
121
+ }
122
+ resolve(result.image);
123
+ });
124
+ });
125
+ return base64;
126
+ }, [capturedDoc, cropRectangle, imageSize]);
127
+ const handleConfirm = (0, react_1.useCallback)(async () => {
128
+ if (!capturedDoc) {
129
+ return;
130
+ }
131
+ try {
132
+ setProcessing(true);
133
+ const base64 = await performCrop();
134
+ setProcessing(false);
135
+ onResult({
136
+ original: capturedDoc,
137
+ rectangle: cropRectangle,
138
+ base64,
139
+ });
140
+ resetState();
141
+ }
142
+ catch (error) {
143
+ setProcessing(false);
144
+ emitError(error instanceof Error ? error : new Error(String(error)), 'Failed to process document.');
145
+ }
146
+ }, [capturedDoc, cropRectangle, emitError, onResult, performCrop, resetState]);
147
+ const handleRetake = (0, react_1.useCallback)(() => {
148
+ resetState();
149
+ }, [resetState]);
150
+ const handleClose = (0, react_1.useCallback)(() => {
151
+ resetState();
152
+ onClose?.();
153
+ }, [onClose, resetState]);
154
+ return (react_1.default.createElement(react_native_1.View, { style: styles.container },
155
+ screen === 'scanner' && (react_1.default.createElement(react_native_1.View, { style: styles.flex },
156
+ react_1.default.createElement(DocScanner_1.DocScanner, { autoCapture: !manualCapture, overlayColor: overlayColor, minStableFrames: minStableFrames ?? 6, detectionConfig: detectionConfig, onCapture: handleCapture },
157
+ react_1.default.createElement(react_native_1.View, { style: styles.overlay, pointerEvents: "box-none" },
158
+ react_1.default.createElement(react_native_1.TouchableOpacity, { style: styles.closeButton, onPress: handleClose, accessibilityLabel: mergedStrings.cancel, accessibilityRole: "button" },
159
+ react_1.default.createElement(react_native_1.Text, { style: styles.closeButtonLabel }, "\u00D7")),
160
+ react_1.default.createElement(react_native_1.View, { style: styles.instructions, pointerEvents: "none" },
161
+ react_1.default.createElement(react_native_1.Text, { style: styles.captureText }, mergedStrings.captureHint),
162
+ manualCapture && react_1.default.createElement(react_native_1.Text, { style: styles.captureText }, mergedStrings.manualHint)))))),
163
+ screen === 'crop' && capturedDoc && (react_1.default.createElement(react_native_1.View, { style: styles.flex },
164
+ react_1.default.createElement(CropEditor_1.CropEditor, { document: capturedDoc, overlayColor: "rgba(0,0,0,0.6)", overlayStrokeColor: overlayStrokeColor, handlerColor: handlerColor, onCropChange: handleCropChange }),
165
+ react_1.default.createElement(react_native_1.View, { style: styles.cropFooter },
166
+ react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.actionButton, styles.secondaryButton], onPress: handleRetake },
167
+ react_1.default.createElement(react_native_1.Text, { style: styles.buttonText }, mergedStrings.retake)),
168
+ react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.actionButton, styles.primaryButton], onPress: handleConfirm },
169
+ react_1.default.createElement(react_native_1.Text, { style: styles.buttonText }, mergedStrings.confirm))))),
170
+ processing && (react_1.default.createElement(react_native_1.View, { style: styles.processingOverlay },
171
+ react_1.default.createElement(react_native_1.ActivityIndicator, { size: "large", color: overlayStrokeColor }),
172
+ react_1.default.createElement(react_native_1.Text, { style: styles.processingText }, mergedStrings.processing)))));
173
+ };
174
+ exports.FullDocScanner = FullDocScanner;
175
+ const styles = react_native_1.StyleSheet.create({
176
+ container: {
177
+ flex: 1,
178
+ backgroundColor: '#000',
179
+ },
180
+ flex: {
181
+ flex: 1,
182
+ },
183
+ overlay: {
184
+ ...react_native_1.StyleSheet.absoluteFillObject,
185
+ justifyContent: 'space-between',
186
+ paddingTop: 48,
187
+ paddingBottom: 64,
188
+ paddingHorizontal: 24,
189
+ },
190
+ closeButton: {
191
+ width: 40,
192
+ height: 40,
193
+ borderRadius: 20,
194
+ backgroundColor: 'rgba(0,0,0,0.5)',
195
+ justifyContent: 'center',
196
+ alignItems: 'center',
197
+ alignSelf: 'flex-end',
198
+ },
199
+ closeButtonLabel: {
200
+ color: '#fff',
201
+ fontSize: 28,
202
+ lineHeight: 32,
203
+ marginTop: -3,
204
+ },
205
+ instructions: {
206
+ alignSelf: 'center',
207
+ backgroundColor: 'rgba(0,0,0,0.55)',
208
+ borderRadius: 16,
209
+ paddingHorizontal: 20,
210
+ paddingVertical: 16,
211
+ },
212
+ captureText: {
213
+ color: '#fff',
214
+ fontSize: 15,
215
+ textAlign: 'center',
216
+ },
217
+ cropFooter: {
218
+ position: 'absolute',
219
+ bottom: 40,
220
+ left: 20,
221
+ right: 20,
222
+ flexDirection: 'row',
223
+ justifyContent: 'space-between',
224
+ },
225
+ actionButton: {
226
+ flex: 1,
227
+ paddingVertical: 14,
228
+ borderRadius: 12,
229
+ alignItems: 'center',
230
+ marginHorizontal: 6,
231
+ },
232
+ secondaryButton: {
233
+ backgroundColor: 'rgba(255,255,255,0.2)',
234
+ borderWidth: 1,
235
+ borderColor: 'rgba(255,255,255,0.35)',
236
+ },
237
+ primaryButton: {
238
+ backgroundColor: '#3170f3',
239
+ },
240
+ buttonText: {
241
+ color: '#fff',
242
+ fontSize: 16,
243
+ fontWeight: '600',
244
+ },
245
+ processingOverlay: {
246
+ ...react_native_1.StyleSheet.absoluteFillObject,
247
+ backgroundColor: 'rgba(0,0,0,0.65)',
248
+ justifyContent: 'center',
249
+ alignItems: 'center',
250
+ },
251
+ processingText: {
252
+ marginTop: 12,
253
+ color: '#fff',
254
+ fontSize: 16,
255
+ fontWeight: '600',
256
+ },
257
+ });
package/dist/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export { DocScanner } from './DocScanner';
2
2
  export { CropEditor } from './CropEditor';
3
+ export { FullDocScanner } from './FullDocScanner';
4
+ export type { FullDocScannerResult, FullDocScannerProps, FullDocScannerStrings, } from './FullDocScanner';
3
5
  export type { Point, Quad, Rectangle, CapturedDocument } from './types';
4
6
  export type { DetectionConfig } from './DocScanner';
5
7
  export { quadToRectangle, rectangleToQuad, scaleCoordinates, scaleRectangle, } from './utils/coordinate';
package/dist/index.js CHANGED
@@ -1,11 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.scaleRectangle = exports.scaleCoordinates = exports.rectangleToQuad = exports.quadToRectangle = exports.CropEditor = exports.DocScanner = void 0;
3
+ exports.scaleRectangle = exports.scaleCoordinates = exports.rectangleToQuad = exports.quadToRectangle = exports.FullDocScanner = exports.CropEditor = exports.DocScanner = void 0;
4
4
  // Main components
5
5
  var DocScanner_1 = require("./DocScanner");
6
6
  Object.defineProperty(exports, "DocScanner", { enumerable: true, get: function () { return DocScanner_1.DocScanner; } });
7
7
  var CropEditor_1 = require("./CropEditor");
8
8
  Object.defineProperty(exports, "CropEditor", { enumerable: true, get: function () { return CropEditor_1.CropEditor; } });
9
+ var FullDocScanner_1 = require("./FullDocScanner");
10
+ Object.defineProperty(exports, "FullDocScanner", { enumerable: true, get: function () { return FullDocScanner_1.FullDocScanner; } });
9
11
  // Utilities
10
12
  var coordinate_1 = require("./utils/coordinate");
11
13
  Object.defineProperty(exports, "quadToRectangle", { enumerable: true, get: function () { return coordinate_1.quadToRectangle; } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-rectangle-doc-scanner",
3
- "version": "0.46.0",
3
+ "version": "0.49.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "repository": {
@@ -352,9 +352,19 @@ export const DocScanner: React.FC<Props> = ({
352
352
  // Bilateral filter for edge-preserving smoothing (better quality than Gaussian)
353
353
  step = 'bilateralFilter';
354
354
  reportStage(step);
355
- const tempMat = OpenCV.createObject(ObjectType.Mat);
356
- OpenCV.invoke('bilateralFilter', mat, tempMat, 9, 75, 75);
357
- mat = tempMat;
355
+ try {
356
+ const tempMat = OpenCV.createObject(ObjectType.Mat);
357
+ OpenCV.invoke('bilateralFilter', mat, tempMat, 9, 75, 75);
358
+ mat = tempMat;
359
+ } catch (error) {
360
+ if (__DEV__) {
361
+ console.warn('[DocScanner] bilateralFilter unavailable, falling back to GaussianBlur', error);
362
+ }
363
+ step = 'gaussianBlurFallback';
364
+ reportStage(step);
365
+ const blurKernel = OpenCV.createObject(ObjectType.Size, 5, 5);
366
+ OpenCV.invoke('GaussianBlur', mat, mat, blurKernel, 0);
367
+ }
358
368
 
359
369
  step = 'Canny';
360
370
  reportStage(step);
@@ -0,0 +1,376 @@
1
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import {
3
+ ActivityIndicator,
4
+ Alert,
5
+ Image,
6
+ NativeModules,
7
+ StyleSheet,
8
+ Text,
9
+ TouchableOpacity,
10
+ View,
11
+ } from 'react-native';
12
+ import { DocScanner } from './DocScanner';
13
+ import { CropEditor } from './CropEditor';
14
+ import type { CapturedDocument, Point, Quad, Rectangle } from './types';
15
+ import type { DetectionConfig } from './DocScanner';
16
+ import { quadToRectangle, scaleRectangle } from './utils/coordinate';
17
+
18
+ type CustomCropManagerType = {
19
+ crop: (
20
+ points: {
21
+ topLeft: Point;
22
+ topRight: Point;
23
+ bottomRight: Point;
24
+ bottomLeft: Point;
25
+ width: number;
26
+ height: number;
27
+ },
28
+ imageUri: string,
29
+ callback: (error: unknown, result: { image: string }) => void,
30
+ ) => void;
31
+ };
32
+
33
+ const stripFileUri = (value: string) => value.replace(/^file:\/\//, '');
34
+
35
+ const ensureFileUri = (value: string) => (value.startsWith('file://') ? value : `file://${value}`);
36
+
37
+ export interface FullDocScannerResult {
38
+ original: CapturedDocument;
39
+ rectangle: Rectangle | null;
40
+ /** Base64-encoded JPEG string returned by CustomCropManager */
41
+ base64: string;
42
+ }
43
+
44
+ export interface FullDocScannerStrings {
45
+ captureHint?: string;
46
+ manualHint?: string;
47
+ cancel?: string;
48
+ confirm?: string;
49
+ retake?: string;
50
+ cropTitle?: string;
51
+ processing?: string;
52
+ }
53
+
54
+ export interface FullDocScannerProps {
55
+ onResult: (result: FullDocScannerResult) => void;
56
+ onClose?: () => void;
57
+ detectionConfig?: DetectionConfig;
58
+ overlayColor?: string;
59
+ overlayStrokeColor?: string;
60
+ handlerColor?: string;
61
+ strings?: FullDocScannerStrings;
62
+ manualCapture?: boolean;
63
+ minStableFrames?: number;
64
+ onError?: (error: Error) => void;
65
+ }
66
+
67
+ type ScreenState = 'scanner' | 'crop';
68
+
69
+ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
70
+ onResult,
71
+ onClose,
72
+ detectionConfig,
73
+ overlayColor = '#3170f3',
74
+ overlayStrokeColor = '#3170f3',
75
+ handlerColor = '#3170f3',
76
+ strings,
77
+ manualCapture = false,
78
+ minStableFrames,
79
+ onError,
80
+ }) => {
81
+ const [screen, setScreen] = useState<ScreenState>('scanner');
82
+ const [capturedDoc, setCapturedDoc] = useState<CapturedDocument | null>(null);
83
+ const [cropRectangle, setCropRectangle] = useState<Rectangle | null>(null);
84
+ const [imageSize, setImageSize] = useState<{ width: number; height: number } | null>(null);
85
+ const [processing, setProcessing] = useState(false);
86
+
87
+ const mergedStrings = useMemo<Required<FullDocScannerStrings>>(
88
+ () => ({
89
+ captureHint: strings?.captureHint ?? 'Align the document within the frame.',
90
+ manualHint: strings?.manualHint ?? 'Tap the button below to capture.',
91
+ cancel: strings?.cancel ?? 'Cancel',
92
+ confirm: strings?.confirm ?? 'Use photo',
93
+ retake: strings?.retake ?? 'Retake',
94
+ cropTitle: strings?.cropTitle ?? 'Adjust the corners',
95
+ processing: strings?.processing ?? 'Processing…',
96
+ }),
97
+ [strings],
98
+ );
99
+
100
+ useEffect(() => {
101
+ if (!capturedDoc) {
102
+ return;
103
+ }
104
+
105
+ Image.getSize(
106
+ ensureFileUri(capturedDoc.path),
107
+ (width, height) => setImageSize({ width, height }),
108
+ () => setImageSize({ width: capturedDoc.width, height: capturedDoc.height }),
109
+ );
110
+ }, [capturedDoc]);
111
+
112
+ const resetState = useCallback(() => {
113
+ setScreen('scanner');
114
+ setCapturedDoc(null);
115
+ setCropRectangle(null);
116
+ setImageSize(null);
117
+ setProcessing(false);
118
+ }, []);
119
+
120
+ const handleCapture = useCallback(
121
+ (document: CapturedDocument) => {
122
+ const normalizedPath = stripFileUri(document.path);
123
+ const nextQuad = document.quad && document.quad.length === 4 ? (document.quad as Quad) : null;
124
+
125
+ setCapturedDoc({
126
+ ...document,
127
+ path: normalizedPath,
128
+ quad: nextQuad,
129
+ });
130
+ setCropRectangle(nextQuad ? quadToRectangle(nextQuad) : null);
131
+ setScreen('crop');
132
+ },
133
+ [],
134
+ );
135
+
136
+ const handleCropChange = useCallback((rectangle: Rectangle) => {
137
+ setCropRectangle(rectangle);
138
+ }, []);
139
+
140
+ const emitError = useCallback(
141
+ (error: Error, fallbackMessage?: string) => {
142
+ console.error('[FullDocScanner] error', error);
143
+ onError?.(error);
144
+ if (!onError && fallbackMessage) {
145
+ Alert.alert('Document Scanner', fallbackMessage);
146
+ }
147
+ },
148
+ [onError],
149
+ );
150
+
151
+ const performCrop = useCallback(async (): Promise<string> => {
152
+ if (!capturedDoc) {
153
+ throw new Error('No captured document to crop');
154
+ }
155
+
156
+ const size = imageSize ?? { width: capturedDoc.width, height: capturedDoc.height };
157
+ const cropManager = NativeModules.CustomCropManager as CustomCropManagerType | undefined;
158
+
159
+ if (!cropManager?.crop) {
160
+ throw new Error('CustomCropManager.crop is not available');
161
+ }
162
+
163
+ const fallbackRectangle =
164
+ capturedDoc.quad && capturedDoc.quad.length === 4
165
+ ? quadToRectangle(capturedDoc.quad as Quad)
166
+ : null;
167
+
168
+ const scaledFallback = fallbackRectangle
169
+ ? scaleRectangle(
170
+ fallbackRectangle,
171
+ capturedDoc.width,
172
+ capturedDoc.height,
173
+ size.width,
174
+ size.height,
175
+ )
176
+ : null;
177
+
178
+ const rectangle = cropRectangle ?? scaledFallback;
179
+
180
+ const base64 = await new Promise<string>((resolve, reject) => {
181
+ cropManager.crop(
182
+ {
183
+ topLeft: rectangle?.topLeft ?? { x: 0, y: 0 },
184
+ topRight: rectangle?.topRight ?? { x: size.width, y: 0 },
185
+ bottomRight: rectangle?.bottomRight ?? { x: size.width, y: size.height },
186
+ bottomLeft: rectangle?.bottomLeft ?? { x: 0, y: size.height },
187
+ width: size.width,
188
+ height: size.height,
189
+ },
190
+ ensureFileUri(capturedDoc.path),
191
+ (error: unknown, result: { image: string }) => {
192
+ if (error) {
193
+ reject(error instanceof Error ? error : new Error('Crop failed'));
194
+ return;
195
+ }
196
+
197
+ resolve(result.image);
198
+ },
199
+ );
200
+ });
201
+
202
+ return base64;
203
+ }, [capturedDoc, cropRectangle, imageSize]);
204
+
205
+ const handleConfirm = useCallback(async () => {
206
+ if (!capturedDoc) {
207
+ return;
208
+ }
209
+
210
+ try {
211
+ setProcessing(true);
212
+ const base64 = await performCrop();
213
+ setProcessing(false);
214
+ onResult({
215
+ original: capturedDoc,
216
+ rectangle: cropRectangle,
217
+ base64,
218
+ });
219
+ resetState();
220
+ } catch (error) {
221
+ setProcessing(false);
222
+ emitError(error instanceof Error ? error : new Error(String(error)), 'Failed to process document.');
223
+ }
224
+ }, [capturedDoc, cropRectangle, emitError, onResult, performCrop, resetState]);
225
+
226
+ const handleRetake = useCallback(() => {
227
+ resetState();
228
+ }, [resetState]);
229
+
230
+ const handleClose = useCallback(() => {
231
+ resetState();
232
+ onClose?.();
233
+ }, [onClose, resetState]);
234
+
235
+ return (
236
+ <View style={styles.container}>
237
+ {screen === 'scanner' && (
238
+ <View style={styles.flex}>
239
+ <DocScanner
240
+ autoCapture={!manualCapture}
241
+ overlayColor={overlayColor}
242
+ minStableFrames={minStableFrames ?? 6}
243
+ detectionConfig={detectionConfig}
244
+ onCapture={handleCapture}
245
+ >
246
+ <View style={styles.overlay} pointerEvents="box-none">
247
+ <TouchableOpacity
248
+ style={styles.closeButton}
249
+ onPress={handleClose}
250
+ accessibilityLabel={mergedStrings.cancel}
251
+ accessibilityRole="button"
252
+ >
253
+ <Text style={styles.closeButtonLabel}>×</Text>
254
+ </TouchableOpacity>
255
+ <View style={styles.instructions} pointerEvents="none">
256
+ <Text style={styles.captureText}>{mergedStrings.captureHint}</Text>
257
+ {manualCapture && <Text style={styles.captureText}>{mergedStrings.manualHint}</Text>}
258
+ </View>
259
+ </View>
260
+ </DocScanner>
261
+ </View>
262
+ )}
263
+
264
+ {screen === 'crop' && capturedDoc && (
265
+ <View style={styles.flex}>
266
+ <CropEditor
267
+ document={capturedDoc}
268
+ overlayColor="rgba(0,0,0,0.6)"
269
+ overlayStrokeColor={overlayStrokeColor}
270
+ handlerColor={handlerColor}
271
+ onCropChange={handleCropChange}
272
+ />
273
+ <View style={styles.cropFooter}>
274
+ <TouchableOpacity style={[styles.actionButton, styles.secondaryButton]} onPress={handleRetake}>
275
+ <Text style={styles.buttonText}>{mergedStrings.retake}</Text>
276
+ </TouchableOpacity>
277
+ <TouchableOpacity style={[styles.actionButton, styles.primaryButton]} onPress={handleConfirm}>
278
+ <Text style={styles.buttonText}>{mergedStrings.confirm}</Text>
279
+ </TouchableOpacity>
280
+ </View>
281
+ </View>
282
+ )}
283
+
284
+ {processing && (
285
+ <View style={styles.processingOverlay}>
286
+ <ActivityIndicator size="large" color={overlayStrokeColor} />
287
+ <Text style={styles.processingText}>{mergedStrings.processing}</Text>
288
+ </View>
289
+ )}
290
+ </View>
291
+ );
292
+ };
293
+
294
+ const styles = StyleSheet.create({
295
+ container: {
296
+ flex: 1,
297
+ backgroundColor: '#000',
298
+ },
299
+ flex: {
300
+ flex: 1,
301
+ },
302
+ overlay: {
303
+ ...StyleSheet.absoluteFillObject,
304
+ justifyContent: 'space-between',
305
+ paddingTop: 48,
306
+ paddingBottom: 64,
307
+ paddingHorizontal: 24,
308
+ },
309
+ closeButton: {
310
+ width: 40,
311
+ height: 40,
312
+ borderRadius: 20,
313
+ backgroundColor: 'rgba(0,0,0,0.5)',
314
+ justifyContent: 'center',
315
+ alignItems: 'center',
316
+ alignSelf: 'flex-end',
317
+ },
318
+ closeButtonLabel: {
319
+ color: '#fff',
320
+ fontSize: 28,
321
+ lineHeight: 32,
322
+ marginTop: -3,
323
+ },
324
+ instructions: {
325
+ alignSelf: 'center',
326
+ backgroundColor: 'rgba(0,0,0,0.55)',
327
+ borderRadius: 16,
328
+ paddingHorizontal: 20,
329
+ paddingVertical: 16,
330
+ },
331
+ captureText: {
332
+ color: '#fff',
333
+ fontSize: 15,
334
+ textAlign: 'center',
335
+ },
336
+ cropFooter: {
337
+ position: 'absolute',
338
+ bottom: 40,
339
+ left: 20,
340
+ right: 20,
341
+ flexDirection: 'row',
342
+ justifyContent: 'space-between',
343
+ },
344
+ actionButton: {
345
+ flex: 1,
346
+ paddingVertical: 14,
347
+ borderRadius: 12,
348
+ alignItems: 'center',
349
+ marginHorizontal: 6,
350
+ },
351
+ secondaryButton: {
352
+ backgroundColor: 'rgba(255,255,255,0.2)',
353
+ borderWidth: 1,
354
+ borderColor: 'rgba(255,255,255,0.35)',
355
+ },
356
+ primaryButton: {
357
+ backgroundColor: '#3170f3',
358
+ },
359
+ buttonText: {
360
+ color: '#fff',
361
+ fontSize: 16,
362
+ fontWeight: '600',
363
+ },
364
+ processingOverlay: {
365
+ ...StyleSheet.absoluteFillObject,
366
+ backgroundColor: 'rgba(0,0,0,0.65)',
367
+ justifyContent: 'center',
368
+ alignItems: 'center',
369
+ },
370
+ processingText: {
371
+ marginTop: 12,
372
+ color: '#fff',
373
+ fontSize: 16,
374
+ fontWeight: '600',
375
+ },
376
+ });
package/src/index.ts CHANGED
@@ -1,6 +1,13 @@
1
1
  // Main components
2
2
  export { DocScanner } from './DocScanner';
3
3
  export { CropEditor } from './CropEditor';
4
+ export { FullDocScanner } from './FullDocScanner';
5
+
6
+ export type {
7
+ FullDocScannerResult,
8
+ FullDocScannerProps,
9
+ FullDocScannerStrings,
10
+ } from './FullDocScanner';
4
11
 
5
12
  // Types
6
13
  export type { Point, Quad, Rectangle, CapturedDocument } from './types';