react-native-rectangle-doc-scanner 0.47.0 → 0.50.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/dist/CropEditor.js +5 -2
- package/dist/FullDocScanner.d.ts +31 -0
- package/dist/FullDocScanner.js +257 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -1
- package/package.json +1 -1
- package/src/CropEditor.tsx +1 -1
- package/src/FullDocScanner.tsx +376 -0
- package/src/external.d.ts +2 -0
- package/src/index.ts +7 -0
package/dist/CropEditor.js
CHANGED
|
@@ -32,11 +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");
|
|
39
|
-
const react_native_perspective_image_cropper_1 = require("react-native-perspective-image-cropper");
|
|
42
|
+
const react_native_perspective_image_cropper_1 = __importDefault(require("react-native-perspective-image-cropper"));
|
|
40
43
|
const coordinate_1 = require("./utils/coordinate");
|
|
41
44
|
/**
|
|
42
45
|
* CropEditor Component
|
|
@@ -97,7 +100,7 @@ const CropEditor = ({ document, overlayColor = 'rgba(0,0,0,0.5)', overlayStrokeC
|
|
|
97
100
|
react_1.default.createElement(react_native_1.Image, { source: { uri: `file://${document.path}` }, style: styles.hiddenImage, onLoad: handleImageLoad, resizeMode: "contain" })));
|
|
98
101
|
}
|
|
99
102
|
return (react_1.default.createElement(react_native_1.View, { style: styles.container, onLayout: handleLayout },
|
|
100
|
-
react_1.default.createElement(react_native_perspective_image_cropper_1.
|
|
103
|
+
react_1.default.createElement(react_native_perspective_image_cropper_1.default, { height: displaySize.height, width: displaySize.width, image: `file://${document.path}`, rectangleCoordinates: getInitialRectangle(), overlayColor: overlayColor, overlayStrokeColor: overlayStrokeColor, handlerColor: handlerColor, enablePanStrict: enablePanStrict, onDragEnd: handleDragEnd })));
|
|
101
104
|
};
|
|
102
105
|
exports.CropEditor = CropEditor;
|
|
103
106
|
const styles = react_native_1.StyleSheet.create({
|
|
@@ -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
package/src/CropEditor.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React, { useState, useCallback } from 'react';
|
|
2
2
|
import { View, StyleSheet, Image, Dimensions } from 'react-native';
|
|
3
|
-
import
|
|
3
|
+
import CustomImageCropper from 'react-native-perspective-image-cropper';
|
|
4
4
|
import type { Rectangle as CropperRectangle } from 'react-native-perspective-image-cropper';
|
|
5
5
|
import type { Point, Rectangle, CapturedDocument } from './types';
|
|
6
6
|
import { quadToRectangle, scaleRectangle } from './utils/coordinate';
|
|
@@ -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/external.d.ts
CHANGED
|
@@ -131,4 +131,6 @@ declare module 'react-native-perspective-image-cropper' {
|
|
|
131
131
|
};
|
|
132
132
|
|
|
133
133
|
export const CustomImageCropper: ComponentType<CustomImageCropperProps>;
|
|
134
|
+
const CustomImageCropperDefault: ComponentType<CustomImageCropperProps>;
|
|
135
|
+
export default CustomImageCropperDefault;
|
|
134
136
|
}
|
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';
|