react-native-rectangle-doc-scanner 3.29.0 → 3.31.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/DocScanner.d.ts +2 -0
- package/dist/DocScanner.js +35 -12
- package/dist/FullDocScanner.js +177 -43
- package/package.json +1 -1
- package/src/DocScanner.tsx +38 -11
- package/src/FullDocScanner.tsx +244 -63
package/dist/DocScanner.d.ts
CHANGED
|
@@ -20,6 +20,7 @@ export type DocScannerCapture = {
|
|
|
20
20
|
rectangle: Rectangle | null;
|
|
21
21
|
width: number;
|
|
22
22
|
height: number;
|
|
23
|
+
origin: 'auto' | 'manual';
|
|
23
24
|
};
|
|
24
25
|
export interface DetectionConfig {
|
|
25
26
|
processingWidth?: number;
|
|
@@ -43,6 +44,7 @@ interface Props {
|
|
|
43
44
|
gridLineWidth?: number;
|
|
44
45
|
detectionConfig?: DetectionConfig;
|
|
45
46
|
onRectangleDetect?: (event: RectangleDetectEvent) => void;
|
|
47
|
+
showManualCaptureButton?: boolean;
|
|
46
48
|
}
|
|
47
49
|
export type DocScannerHandle = {
|
|
48
50
|
capture: () => Promise<PictureEvent>;
|
package/dist/DocScanner.js
CHANGED
|
@@ -68,12 +68,13 @@ const normalizeRectangle = (rectangle) => {
|
|
|
68
68
|
};
|
|
69
69
|
};
|
|
70
70
|
const DEFAULT_OVERLAY_COLOR = '#0b7ef4';
|
|
71
|
-
exports.DocScanner = (0, react_1.forwardRef)(({ onCapture, overlayColor = DEFAULT_OVERLAY_COLOR, autoCapture = true, minStableFrames = 8, enableTorch = false, quality = 90, useBase64 = false, children, showGrid = true, gridColor, gridLineWidth, detectionConfig, onRectangleDetect, }, ref) => {
|
|
71
|
+
exports.DocScanner = (0, react_1.forwardRef)(({ onCapture, overlayColor = DEFAULT_OVERLAY_COLOR, autoCapture = true, minStableFrames = 8, enableTorch = false, quality = 90, useBase64 = false, children, showGrid = true, gridColor, gridLineWidth, detectionConfig, onRectangleDetect, showManualCaptureButton = false, }, ref) => {
|
|
72
72
|
const scannerRef = (0, react_1.useRef)(null);
|
|
73
73
|
const captureResolvers = (0, react_1.useRef)(null);
|
|
74
74
|
const [isAutoCapturing, setIsAutoCapturing] = (0, react_1.useState)(false);
|
|
75
75
|
const [detectedRectangle, setDetectedRectangle] = (0, react_1.useState)(null);
|
|
76
76
|
const lastRectangleRef = (0, react_1.useRef)(null);
|
|
77
|
+
const captureOriginRef = (0, react_1.useRef)('auto');
|
|
77
78
|
(0, react_1.useEffect)(() => {
|
|
78
79
|
if (!autoCapture) {
|
|
79
80
|
setIsAutoCapturing(false);
|
|
@@ -90,6 +91,8 @@ exports.DocScanner = (0, react_1.forwardRef)(({ onCapture, overlayColor = DEFAUL
|
|
|
90
91
|
setIsAutoCapturing(false);
|
|
91
92
|
const normalizedRectangle = normalizeRectangle(event.rectangleCoordinates ?? null) ?? lastRectangleRef.current;
|
|
92
93
|
const quad = normalizedRectangle ? (0, coordinate_1.rectangleToQuad)(normalizedRectangle) : null;
|
|
94
|
+
const origin = captureOriginRef.current;
|
|
95
|
+
captureOriginRef.current = 'auto';
|
|
93
96
|
const initialPath = event.initialImage ?? null;
|
|
94
97
|
const croppedPath = event.croppedImage ?? null;
|
|
95
98
|
const editablePath = initialPath ?? croppedPath;
|
|
@@ -102,6 +105,7 @@ exports.DocScanner = (0, react_1.forwardRef)(({ onCapture, overlayColor = DEFAUL
|
|
|
102
105
|
rectangle: normalizedRectangle,
|
|
103
106
|
width: event.width ?? 0,
|
|
104
107
|
height: event.height ?? 0,
|
|
108
|
+
origin,
|
|
105
109
|
});
|
|
106
110
|
}
|
|
107
111
|
setDetectedRectangle(null);
|
|
@@ -117,32 +121,50 @@ exports.DocScanner = (0, react_1.forwardRef)(({ onCapture, overlayColor = DEFAUL
|
|
|
117
121
|
}
|
|
118
122
|
}, []);
|
|
119
123
|
const capture = (0, react_1.useCallback)(() => {
|
|
124
|
+
captureOriginRef.current = 'manual';
|
|
120
125
|
const instance = scannerRef.current;
|
|
121
126
|
if (!instance || typeof instance.capture !== 'function') {
|
|
127
|
+
captureOriginRef.current = 'auto';
|
|
122
128
|
return Promise.reject(new Error('DocumentScanner native instance is not ready'));
|
|
123
129
|
}
|
|
124
130
|
if (captureResolvers.current) {
|
|
131
|
+
captureOriginRef.current = 'auto';
|
|
125
132
|
return Promise.reject(new Error('capture_in_progress'));
|
|
126
133
|
}
|
|
127
|
-
|
|
134
|
+
let result;
|
|
135
|
+
try {
|
|
136
|
+
result = instance.capture();
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
captureOriginRef.current = 'auto';
|
|
140
|
+
return Promise.reject(error);
|
|
141
|
+
}
|
|
128
142
|
if (result && typeof result.then === 'function') {
|
|
129
|
-
return result.
|
|
130
|
-
|
|
131
|
-
|
|
143
|
+
return result.catch((error) => {
|
|
144
|
+
captureOriginRef.current = 'auto';
|
|
145
|
+
throw error;
|
|
132
146
|
});
|
|
133
147
|
}
|
|
134
148
|
return new Promise((resolve, reject) => {
|
|
135
|
-
captureResolvers.current = {
|
|
149
|
+
captureResolvers.current = {
|
|
150
|
+
resolve: (value) => {
|
|
151
|
+
captureOriginRef.current = 'auto';
|
|
152
|
+
resolve(value);
|
|
153
|
+
},
|
|
154
|
+
reject: (reason) => {
|
|
155
|
+
captureOriginRef.current = 'auto';
|
|
156
|
+
reject(reason);
|
|
157
|
+
},
|
|
158
|
+
};
|
|
136
159
|
});
|
|
137
|
-
}, [
|
|
160
|
+
}, []);
|
|
138
161
|
const handleManualCapture = (0, react_1.useCallback)(() => {
|
|
139
|
-
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
162
|
+
captureOriginRef.current = 'manual';
|
|
142
163
|
capture().catch((error) => {
|
|
164
|
+
captureOriginRef.current = 'auto';
|
|
143
165
|
console.warn('[DocScanner] manual capture failed', error);
|
|
144
166
|
});
|
|
145
|
-
}, [
|
|
167
|
+
}, [capture]);
|
|
146
168
|
const handleRectangleDetect = (0, react_1.useCallback)((event) => {
|
|
147
169
|
const rectangleCoordinates = normalizeRectangle(event.rectangleCoordinates ?? null);
|
|
148
170
|
const rectangleOnScreen = normalizeRectangle(event.rectangleOnScreen ?? null);
|
|
@@ -173,6 +195,7 @@ exports.DocScanner = (0, react_1.forwardRef)(({ onCapture, overlayColor = DEFAUL
|
|
|
173
195
|
captureResolvers.current.reject(new Error('reset'));
|
|
174
196
|
captureResolvers.current = null;
|
|
175
197
|
}
|
|
198
|
+
captureOriginRef.current = 'auto';
|
|
176
199
|
},
|
|
177
200
|
}), [capture]);
|
|
178
201
|
const overlayPolygon = detectedRectangle?.rectangleOnScreen ?? detectedRectangle?.rectangleCoordinates ?? null;
|
|
@@ -180,7 +203,7 @@ exports.DocScanner = (0, react_1.forwardRef)(({ onCapture, overlayColor = DEFAUL
|
|
|
180
203
|
return (react_1.default.createElement(react_native_1.View, { style: styles.container },
|
|
181
204
|
react_1.default.createElement(react_native_document_scanner_1.default, { ref: scannerRef, style: styles.scanner, detectionCountBeforeCapture: minStableFrames, overlayColor: overlayColor, enableTorch: enableTorch, quality: normalizedQuality, useBase64: useBase64, manualOnly: !autoCapture, detectionConfig: detectionConfig, onPictureTaken: handlePictureTaken, onError: handleError, onRectangleDetect: handleRectangleDetect }),
|
|
182
205
|
showGrid && overlayPolygon && (react_1.default.createElement(overlay_1.ScannerOverlay, { active: overlayIsActive, color: gridColor ?? overlayColor, lineWidth: gridLineWidth, polygon: overlayPolygon })),
|
|
183
|
-
!autoCapture && react_1.default.createElement(react_native_1.TouchableOpacity, { style: styles.button, onPress: handleManualCapture }),
|
|
206
|
+
(showManualCaptureButton || !autoCapture) && (react_1.default.createElement(react_native_1.TouchableOpacity, { style: styles.button, onPress: handleManualCapture })),
|
|
184
207
|
children));
|
|
185
208
|
});
|
|
186
209
|
const styles = react_native_1.StyleSheet.create({
|
package/dist/FullDocScanner.js
CHANGED
|
@@ -41,6 +41,27 @@ const CropEditor_1 = require("./CropEditor");
|
|
|
41
41
|
const coordinate_1 = require("./utils/coordinate");
|
|
42
42
|
const stripFileUri = (value) => value.replace(/^file:\/\//, '');
|
|
43
43
|
const ensureFileUri = (value) => (value.startsWith('file://') ? value : `file://${value}`);
|
|
44
|
+
const createFullImageRectangle = (width, height) => ({
|
|
45
|
+
topLeft: { x: 0, y: 0 },
|
|
46
|
+
topRight: { x: width, y: 0 },
|
|
47
|
+
bottomRight: { x: width, y: height },
|
|
48
|
+
bottomLeft: { x: 0, y: height },
|
|
49
|
+
});
|
|
50
|
+
const resolveImageSize = (path, fallbackWidth, fallbackHeight) => new Promise((resolve) => {
|
|
51
|
+
react_native_1.Image.getSize(ensureFileUri(path), (width, height) => resolve({ width, height }), () => resolve({
|
|
52
|
+
width: fallbackWidth > 0 ? fallbackWidth : 0,
|
|
53
|
+
height: fallbackHeight > 0 ? fallbackHeight : 0,
|
|
54
|
+
}));
|
|
55
|
+
});
|
|
56
|
+
const normalizeCapturedDocument = (document) => {
|
|
57
|
+
const normalizedPath = stripFileUri(document.initialPath ?? document.path);
|
|
58
|
+
return {
|
|
59
|
+
...document,
|
|
60
|
+
path: normalizedPath,
|
|
61
|
+
initialPath: document.initialPath ? stripFileUri(document.initialPath) : normalizedPath,
|
|
62
|
+
croppedPath: document.croppedPath ? stripFileUri(document.croppedPath) : null,
|
|
63
|
+
};
|
|
64
|
+
};
|
|
44
65
|
const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3170f3', gridColor, gridLineWidth, showGrid, overlayStrokeColor = '#3170f3', handlerColor = '#3170f3', strings, manualCapture = false, minStableFrames, onError, }) => {
|
|
45
66
|
const [screen, setScreen] = (0, react_1.useState)('scanner');
|
|
46
67
|
const [capturedDoc, setCapturedDoc] = (0, react_1.useState)(null);
|
|
@@ -48,6 +69,10 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
48
69
|
const [imageSize, setImageSize] = (0, react_1.useState)(null);
|
|
49
70
|
const [processing, setProcessing] = (0, react_1.useState)(false);
|
|
50
71
|
const resolvedGridColor = gridColor ?? overlayColor;
|
|
72
|
+
const docScannerRef = (0, react_1.useRef)(null);
|
|
73
|
+
const manualCapturePending = (0, react_1.useRef)(false);
|
|
74
|
+
const processingCaptureRef = (0, react_1.useRef)(false);
|
|
75
|
+
const cropInitializedRef = (0, react_1.useRef)(false);
|
|
51
76
|
const mergedStrings = (0, react_1.useMemo)(() => ({
|
|
52
77
|
captureHint: strings?.captureHint ?? 'Align the document within the frame.',
|
|
53
78
|
manualHint: strings?.manualHint ?? 'Tap the button below to capture.',
|
|
@@ -64,7 +89,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
64
89
|
react_native_1.Image.getSize(ensureFileUri(capturedDoc.path), (width, height) => setImageSize({ width, height }), () => setImageSize({ width: capturedDoc.width, height: capturedDoc.height }));
|
|
65
90
|
}, [capturedDoc]);
|
|
66
91
|
(0, react_1.useEffect)(() => {
|
|
67
|
-
if (!capturedDoc || !imageSize ||
|
|
92
|
+
if (!capturedDoc || !imageSize || cropInitializedRef.current) {
|
|
68
93
|
return;
|
|
69
94
|
}
|
|
70
95
|
const baseWidth = capturedDoc.width > 0 ? capturedDoc.width : imageSize.width;
|
|
@@ -80,36 +105,19 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
80
105
|
}
|
|
81
106
|
}
|
|
82
107
|
if (initialRectangle) {
|
|
108
|
+
cropInitializedRef.current = true;
|
|
83
109
|
setCropRectangle(initialRectangle);
|
|
84
110
|
}
|
|
85
|
-
}, [capturedDoc, imageSize
|
|
111
|
+
}, [capturedDoc, imageSize]);
|
|
86
112
|
const resetState = (0, react_1.useCallback)(() => {
|
|
87
113
|
setScreen('scanner');
|
|
88
114
|
setCapturedDoc(null);
|
|
89
115
|
setCropRectangle(null);
|
|
90
116
|
setImageSize(null);
|
|
91
117
|
setProcessing(false);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const nextQuad = document.quad && document.quad.length === 4 ? document.quad : null;
|
|
96
|
-
const quadRectangle = nextQuad ? (0, coordinate_1.quadToRectangle)(nextQuad) : null;
|
|
97
|
-
const nextRectangle = document.rectangle ?? quadRectangle ?? null;
|
|
98
|
-
const normalizedInitial = document.initialPath != null ? stripFileUri(document.initialPath) : normalizedPath;
|
|
99
|
-
const normalizedCropped = document.croppedPath != null ? stripFileUri(document.croppedPath) : null;
|
|
100
|
-
setCapturedDoc({
|
|
101
|
-
...document,
|
|
102
|
-
path: normalizedPath,
|
|
103
|
-
initialPath: normalizedInitial,
|
|
104
|
-
croppedPath: normalizedCropped,
|
|
105
|
-
quad: nextQuad,
|
|
106
|
-
rectangle: nextRectangle,
|
|
107
|
-
});
|
|
108
|
-
setCropRectangle(null);
|
|
109
|
-
setScreen('crop');
|
|
110
|
-
}, []);
|
|
111
|
-
const handleCropChange = (0, react_1.useCallback)((rectangle) => {
|
|
112
|
-
setCropRectangle(rectangle);
|
|
118
|
+
manualCapturePending.current = false;
|
|
119
|
+
processingCaptureRef.current = false;
|
|
120
|
+
cropInitializedRef.current = false;
|
|
113
121
|
}, []);
|
|
114
122
|
const emitError = (0, react_1.useCallback)((error, fallbackMessage) => {
|
|
115
123
|
console.error('[FullDocScanner] error', error);
|
|
@@ -118,6 +126,104 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
118
126
|
react_native_1.Alert.alert('Document Scanner', fallbackMessage);
|
|
119
127
|
}
|
|
120
128
|
}, [onError]);
|
|
129
|
+
const processAutoCapture = (0, react_1.useCallback)(async (document) => {
|
|
130
|
+
manualCapturePending.current = false;
|
|
131
|
+
const normalizedDoc = normalizeCapturedDocument(document);
|
|
132
|
+
const cropManager = react_native_1.NativeModules.CustomCropManager;
|
|
133
|
+
if (!cropManager?.crop) {
|
|
134
|
+
emitError(new Error('CustomCropManager.crop is not available'));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
setProcessing(true);
|
|
138
|
+
try {
|
|
139
|
+
const size = await resolveImageSize(normalizedDoc.path, normalizedDoc.width, normalizedDoc.height);
|
|
140
|
+
const targetWidthRaw = size.width > 0 ? size.width : normalizedDoc.width;
|
|
141
|
+
const targetHeightRaw = size.height > 0 ? size.height : normalizedDoc.height;
|
|
142
|
+
const baseWidth = normalizedDoc.width > 0 ? normalizedDoc.width : targetWidthRaw;
|
|
143
|
+
const baseHeight = normalizedDoc.height > 0 ? normalizedDoc.height : targetHeightRaw;
|
|
144
|
+
const targetWidth = targetWidthRaw > 0 ? targetWidthRaw : baseWidth || 1;
|
|
145
|
+
const targetHeight = targetHeightRaw > 0 ? targetHeightRaw : baseHeight || 1;
|
|
146
|
+
let rectangleBase = normalizedDoc.rectangle ?? null;
|
|
147
|
+
if (!rectangleBase && normalizedDoc.quad && normalizedDoc.quad.length === 4) {
|
|
148
|
+
rectangleBase = (0, coordinate_1.quadToRectangle)(normalizedDoc.quad);
|
|
149
|
+
}
|
|
150
|
+
const scaledRectangle = rectangleBase
|
|
151
|
+
? (0, coordinate_1.scaleRectangle)(rectangleBase, baseWidth || targetWidth, baseHeight || targetHeight, targetWidth, targetHeight)
|
|
152
|
+
: null;
|
|
153
|
+
const rectangleToUse = scaledRectangle ?? createFullImageRectangle(targetWidth, targetHeight);
|
|
154
|
+
const base64 = await new Promise((resolve, reject) => {
|
|
155
|
+
cropManager.crop({
|
|
156
|
+
topLeft: rectangleToUse.topLeft,
|
|
157
|
+
topRight: rectangleToUse.topRight,
|
|
158
|
+
bottomRight: rectangleToUse.bottomRight,
|
|
159
|
+
bottomLeft: rectangleToUse.bottomLeft,
|
|
160
|
+
width: targetWidth,
|
|
161
|
+
height: targetHeight,
|
|
162
|
+
}, ensureFileUri(normalizedDoc.path), (error, result) => {
|
|
163
|
+
if (error) {
|
|
164
|
+
reject(error instanceof Error ? error : new Error('Crop failed'));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
resolve(result.image);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
const finalDoc = {
|
|
171
|
+
...normalizedDoc,
|
|
172
|
+
rectangle: rectangleToUse,
|
|
173
|
+
};
|
|
174
|
+
onResult({
|
|
175
|
+
original: finalDoc,
|
|
176
|
+
rectangle: rectangleToUse,
|
|
177
|
+
base64,
|
|
178
|
+
});
|
|
179
|
+
resetState();
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
setProcessing(false);
|
|
183
|
+
emitError(error instanceof Error ? error : new Error(String(error)), 'Failed to process document.');
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
processingCaptureRef.current = false;
|
|
187
|
+
}
|
|
188
|
+
}, [emitError, onResult, resetState]);
|
|
189
|
+
const handleCapture = (0, react_1.useCallback)((document) => {
|
|
190
|
+
if (processingCaptureRef.current) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const isManualCapture = manualCapture || manualCapturePending.current || document.origin === 'manual';
|
|
194
|
+
const normalizedDoc = normalizeCapturedDocument(document);
|
|
195
|
+
if (isManualCapture) {
|
|
196
|
+
manualCapturePending.current = false;
|
|
197
|
+
processingCaptureRef.current = false;
|
|
198
|
+
cropInitializedRef.current = false;
|
|
199
|
+
setCapturedDoc(normalizedDoc);
|
|
200
|
+
setImageSize(null);
|
|
201
|
+
setCropRectangle(null);
|
|
202
|
+
setScreen('crop');
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
processingCaptureRef.current = true;
|
|
206
|
+
processAutoCapture(document);
|
|
207
|
+
}, [manualCapture, processAutoCapture]);
|
|
208
|
+
const handleCropChange = (0, react_1.useCallback)((rectangle) => {
|
|
209
|
+
setCropRectangle(rectangle);
|
|
210
|
+
}, []);
|
|
211
|
+
const triggerManualCapture = (0, react_1.useCallback)(() => {
|
|
212
|
+
if (processingCaptureRef.current) {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
manualCapturePending.current = true;
|
|
216
|
+
const capturePromise = docScannerRef.current?.capture();
|
|
217
|
+
if (capturePromise && typeof capturePromise.catch === 'function') {
|
|
218
|
+
capturePromise.catch((error) => {
|
|
219
|
+
manualCapturePending.current = false;
|
|
220
|
+
console.warn('[FullDocScanner] manual capture failed', error);
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
else if (!capturePromise) {
|
|
224
|
+
manualCapturePending.current = false;
|
|
225
|
+
}
|
|
226
|
+
}, []);
|
|
121
227
|
const performCrop = (0, react_1.useCallback)(async () => {
|
|
122
228
|
if (!capturedDoc) {
|
|
123
229
|
throw new Error('No captured document to crop');
|
|
@@ -129,25 +235,27 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
129
235
|
}
|
|
130
236
|
const baseWidth = capturedDoc.width > 0 ? capturedDoc.width : size.width;
|
|
131
237
|
const baseHeight = capturedDoc.height > 0 ? capturedDoc.height : size.height;
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
238
|
+
const targetWidth = size.width > 0 ? size.width : baseWidth || 1;
|
|
239
|
+
const targetHeight = size.height > 0 ? size.height : baseHeight || 1;
|
|
240
|
+
let fallbackRectangle = null;
|
|
241
|
+
if (capturedDoc.rectangle) {
|
|
242
|
+
fallbackRectangle = (0, coordinate_1.scaleRectangle)(capturedDoc.rectangle, baseWidth || targetWidth, baseHeight || targetHeight, targetWidth, targetHeight);
|
|
243
|
+
}
|
|
244
|
+
else if (capturedDoc.quad && capturedDoc.quad.length === 4) {
|
|
137
245
|
const quadRectangle = (0, coordinate_1.quadToRectangle)(capturedDoc.quad);
|
|
138
246
|
if (quadRectangle) {
|
|
139
|
-
fallbackRectangle = (0, coordinate_1.scaleRectangle)(quadRectangle, baseWidth, baseHeight,
|
|
247
|
+
fallbackRectangle = (0, coordinate_1.scaleRectangle)(quadRectangle, baseWidth || targetWidth, baseHeight || targetHeight, targetWidth, targetHeight);
|
|
140
248
|
}
|
|
141
249
|
}
|
|
142
|
-
const
|
|
250
|
+
const rectangleToUse = cropRectangle ?? fallbackRectangle ?? createFullImageRectangle(targetWidth, targetHeight);
|
|
143
251
|
const base64 = await new Promise((resolve, reject) => {
|
|
144
252
|
cropManager.crop({
|
|
145
|
-
topLeft:
|
|
146
|
-
topRight:
|
|
147
|
-
bottomRight:
|
|
148
|
-
bottomLeft:
|
|
149
|
-
width:
|
|
150
|
-
height:
|
|
253
|
+
topLeft: rectangleToUse.topLeft,
|
|
254
|
+
topRight: rectangleToUse.topRight,
|
|
255
|
+
bottomRight: rectangleToUse.bottomRight,
|
|
256
|
+
bottomLeft: rectangleToUse.bottomLeft,
|
|
257
|
+
width: targetWidth,
|
|
258
|
+
height: targetHeight,
|
|
151
259
|
}, ensureFileUri(capturedDoc.path), (error, result) => {
|
|
152
260
|
if (error) {
|
|
153
261
|
reject(error instanceof Error ? error : new Error('Crop failed'));
|
|
@@ -156,7 +264,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
156
264
|
resolve(result.image);
|
|
157
265
|
});
|
|
158
266
|
});
|
|
159
|
-
return base64;
|
|
267
|
+
return { base64, rectangle: rectangleToUse };
|
|
160
268
|
}, [capturedDoc, cropRectangle, imageSize]);
|
|
161
269
|
const handleConfirm = (0, react_1.useCallback)(async () => {
|
|
162
270
|
if (!capturedDoc) {
|
|
@@ -164,11 +272,15 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
164
272
|
}
|
|
165
273
|
try {
|
|
166
274
|
setProcessing(true);
|
|
167
|
-
const base64 = await performCrop();
|
|
275
|
+
const { base64, rectangle } = await performCrop();
|
|
168
276
|
setProcessing(false);
|
|
277
|
+
const finalDoc = {
|
|
278
|
+
...capturedDoc,
|
|
279
|
+
rectangle,
|
|
280
|
+
};
|
|
169
281
|
onResult({
|
|
170
|
-
original:
|
|
171
|
-
rectangle
|
|
282
|
+
original: finalDoc,
|
|
283
|
+
rectangle,
|
|
172
284
|
base64,
|
|
173
285
|
});
|
|
174
286
|
resetState();
|
|
@@ -177,7 +289,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
177
289
|
setProcessing(false);
|
|
178
290
|
emitError(error instanceof Error ? error : new Error(String(error)), 'Failed to process document.');
|
|
179
291
|
}
|
|
180
|
-
}, [capturedDoc,
|
|
292
|
+
}, [capturedDoc, emitError, onResult, performCrop, resetState]);
|
|
181
293
|
const handleRetake = (0, react_1.useCallback)(() => {
|
|
182
294
|
resetState();
|
|
183
295
|
}, [resetState]);
|
|
@@ -187,13 +299,15 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
187
299
|
}, [onClose, resetState]);
|
|
188
300
|
return (react_1.default.createElement(react_native_1.View, { style: styles.container },
|
|
189
301
|
screen === 'scanner' && (react_1.default.createElement(react_native_1.View, { style: styles.flex },
|
|
190
|
-
react_1.default.createElement(DocScanner_1.DocScanner, { autoCapture: !manualCapture, overlayColor: overlayColor, showGrid: showGrid, gridColor: resolvedGridColor, gridLineWidth: gridLineWidth, minStableFrames: minStableFrames ?? 6, detectionConfig: detectionConfig, onCapture: handleCapture },
|
|
302
|
+
react_1.default.createElement(DocScanner_1.DocScanner, { ref: docScannerRef, autoCapture: !manualCapture, overlayColor: overlayColor, showGrid: showGrid, gridColor: resolvedGridColor, gridLineWidth: gridLineWidth, minStableFrames: minStableFrames ?? 6, detectionConfig: detectionConfig, onCapture: handleCapture },
|
|
191
303
|
react_1.default.createElement(react_native_1.View, { style: styles.overlay, pointerEvents: "box-none" },
|
|
192
304
|
react_1.default.createElement(react_native_1.TouchableOpacity, { style: styles.closeButton, onPress: handleClose, accessibilityLabel: mergedStrings.cancel, accessibilityRole: "button" },
|
|
193
305
|
react_1.default.createElement(react_native_1.Text, { style: styles.closeButtonLabel }, "\u00D7")),
|
|
194
306
|
react_1.default.createElement(react_native_1.View, { style: styles.instructions, pointerEvents: "none" },
|
|
195
307
|
react_1.default.createElement(react_native_1.Text, { style: styles.captureText }, mergedStrings.captureHint),
|
|
196
|
-
|
|
308
|
+
react_1.default.createElement(react_native_1.Text, { style: styles.captureText }, mergedStrings.manualHint)),
|
|
309
|
+
react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.shutterButton, processing && styles.shutterButtonDisabled], onPress: triggerManualCapture, disabled: processing, accessibilityLabel: mergedStrings.manualHint, accessibilityRole: "button" },
|
|
310
|
+
react_1.default.createElement(react_native_1.View, { style: styles.shutterInner })))))),
|
|
197
311
|
screen === 'crop' && capturedDoc && (react_1.default.createElement(react_native_1.View, { style: styles.flex },
|
|
198
312
|
react_1.default.createElement(CropEditor_1.CropEditor, { document: capturedDoc, overlayColor: "rgba(0,0,0,0.6)", overlayStrokeColor: overlayStrokeColor, handlerColor: handlerColor, onCropChange: handleCropChange }),
|
|
199
313
|
react_1.default.createElement(react_native_1.View, { style: styles.cropFooter },
|
|
@@ -248,6 +362,26 @@ const styles = react_native_1.StyleSheet.create({
|
|
|
248
362
|
fontSize: 15,
|
|
249
363
|
textAlign: 'center',
|
|
250
364
|
},
|
|
365
|
+
shutterButton: {
|
|
366
|
+
alignSelf: 'center',
|
|
367
|
+
width: 80,
|
|
368
|
+
height: 80,
|
|
369
|
+
borderRadius: 40,
|
|
370
|
+
borderWidth: 4,
|
|
371
|
+
borderColor: '#fff',
|
|
372
|
+
justifyContent: 'center',
|
|
373
|
+
alignItems: 'center',
|
|
374
|
+
backgroundColor: 'rgba(255,255,255,0.1)',
|
|
375
|
+
},
|
|
376
|
+
shutterButtonDisabled: {
|
|
377
|
+
opacity: 0.4,
|
|
378
|
+
},
|
|
379
|
+
shutterInner: {
|
|
380
|
+
width: 60,
|
|
381
|
+
height: 60,
|
|
382
|
+
borderRadius: 30,
|
|
383
|
+
backgroundColor: '#fff',
|
|
384
|
+
},
|
|
251
385
|
cropFooter: {
|
|
252
386
|
position: 'absolute',
|
|
253
387
|
bottom: 40,
|
package/package.json
CHANGED
package/src/DocScanner.tsx
CHANGED
|
@@ -36,6 +36,7 @@ export type DocScannerCapture = {
|
|
|
36
36
|
rectangle: Rectangle | null;
|
|
37
37
|
width: number;
|
|
38
38
|
height: number;
|
|
39
|
+
origin: 'auto' | 'manual';
|
|
39
40
|
};
|
|
40
41
|
|
|
41
42
|
const isFiniteNumber = (value: unknown): value is number =>
|
|
@@ -93,6 +94,7 @@ interface Props {
|
|
|
93
94
|
gridLineWidth?: number;
|
|
94
95
|
detectionConfig?: DetectionConfig;
|
|
95
96
|
onRectangleDetect?: (event: RectangleDetectEvent) => void;
|
|
97
|
+
showManualCaptureButton?: boolean;
|
|
96
98
|
}
|
|
97
99
|
|
|
98
100
|
export type DocScannerHandle = {
|
|
@@ -118,6 +120,7 @@ export const DocScanner = forwardRef<DocScannerHandle, Props>(
|
|
|
118
120
|
gridLineWidth,
|
|
119
121
|
detectionConfig,
|
|
120
122
|
onRectangleDetect,
|
|
123
|
+
showManualCaptureButton = false,
|
|
121
124
|
},
|
|
122
125
|
ref,
|
|
123
126
|
) => {
|
|
@@ -129,6 +132,7 @@ export const DocScanner = forwardRef<DocScannerHandle, Props>(
|
|
|
129
132
|
const [isAutoCapturing, setIsAutoCapturing] = useState(false);
|
|
130
133
|
const [detectedRectangle, setDetectedRectangle] = useState<RectangleDetectEvent | null>(null);
|
|
131
134
|
const lastRectangleRef = useRef<Rectangle | null>(null);
|
|
135
|
+
const captureOriginRef = useRef<'auto' | 'manual'>('auto');
|
|
132
136
|
|
|
133
137
|
useEffect(() => {
|
|
134
138
|
if (!autoCapture) {
|
|
@@ -151,6 +155,8 @@ export const DocScanner = forwardRef<DocScannerHandle, Props>(
|
|
|
151
155
|
const normalizedRectangle =
|
|
152
156
|
normalizeRectangle(event.rectangleCoordinates ?? null) ?? lastRectangleRef.current;
|
|
153
157
|
const quad = normalizedRectangle ? rectangleToQuad(normalizedRectangle) : null;
|
|
158
|
+
const origin = captureOriginRef.current;
|
|
159
|
+
captureOriginRef.current = 'auto';
|
|
154
160
|
|
|
155
161
|
const initialPath = event.initialImage ?? null;
|
|
156
162
|
const croppedPath = event.croppedImage ?? null;
|
|
@@ -165,6 +171,7 @@ export const DocScanner = forwardRef<DocScannerHandle, Props>(
|
|
|
165
171
|
rectangle: normalizedRectangle,
|
|
166
172
|
width: event.width ?? 0,
|
|
167
173
|
height: event.height ?? 0,
|
|
174
|
+
origin,
|
|
168
175
|
});
|
|
169
176
|
}
|
|
170
177
|
|
|
@@ -186,35 +193,52 @@ export const DocScanner = forwardRef<DocScannerHandle, Props>(
|
|
|
186
193
|
}, []);
|
|
187
194
|
|
|
188
195
|
const capture = useCallback((): Promise<PictureEvent> => {
|
|
196
|
+
captureOriginRef.current = 'manual';
|
|
189
197
|
const instance = scannerRef.current;
|
|
190
198
|
if (!instance || typeof instance.capture !== 'function') {
|
|
199
|
+
captureOriginRef.current = 'auto';
|
|
191
200
|
return Promise.reject(new Error('DocumentScanner native instance is not ready'));
|
|
192
201
|
}
|
|
193
202
|
if (captureResolvers.current) {
|
|
203
|
+
captureOriginRef.current = 'auto';
|
|
194
204
|
return Promise.reject(new Error('capture_in_progress'));
|
|
195
205
|
}
|
|
196
206
|
|
|
197
|
-
|
|
207
|
+
let result: any;
|
|
208
|
+
try {
|
|
209
|
+
result = instance.capture();
|
|
210
|
+
} catch (error) {
|
|
211
|
+
captureOriginRef.current = 'auto';
|
|
212
|
+
return Promise.reject(error);
|
|
213
|
+
}
|
|
198
214
|
if (result && typeof result.then === 'function') {
|
|
199
|
-
return result.
|
|
200
|
-
|
|
201
|
-
|
|
215
|
+
return result.catch((error: unknown) => {
|
|
216
|
+
captureOriginRef.current = 'auto';
|
|
217
|
+
throw error;
|
|
202
218
|
});
|
|
203
219
|
}
|
|
204
220
|
|
|
205
221
|
return new Promise<PictureEvent>((resolve, reject) => {
|
|
206
|
-
captureResolvers.current = {
|
|
222
|
+
captureResolvers.current = {
|
|
223
|
+
resolve: (value) => {
|
|
224
|
+
captureOriginRef.current = 'auto';
|
|
225
|
+
resolve(value);
|
|
226
|
+
},
|
|
227
|
+
reject: (reason) => {
|
|
228
|
+
captureOriginRef.current = 'auto';
|
|
229
|
+
reject(reason);
|
|
230
|
+
},
|
|
231
|
+
};
|
|
207
232
|
});
|
|
208
|
-
}, [
|
|
233
|
+
}, []);
|
|
209
234
|
|
|
210
235
|
const handleManualCapture = useCallback(() => {
|
|
211
|
-
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
236
|
+
captureOriginRef.current = 'manual';
|
|
214
237
|
capture().catch((error) => {
|
|
238
|
+
captureOriginRef.current = 'auto';
|
|
215
239
|
console.warn('[DocScanner] manual capture failed', error);
|
|
216
240
|
});
|
|
217
|
-
}, [
|
|
241
|
+
}, [capture]);
|
|
218
242
|
|
|
219
243
|
const handleRectangleDetect = useCallback(
|
|
220
244
|
(event: RectangleEventPayload) => {
|
|
@@ -255,6 +279,7 @@ export const DocScanner = forwardRef<DocScannerHandle, Props>(
|
|
|
255
279
|
captureResolvers.current.reject(new Error('reset'));
|
|
256
280
|
captureResolvers.current = null;
|
|
257
281
|
}
|
|
282
|
+
captureOriginRef.current = 'auto';
|
|
258
283
|
},
|
|
259
284
|
}),
|
|
260
285
|
[capture],
|
|
@@ -287,7 +312,9 @@ export const DocScanner = forwardRef<DocScannerHandle, Props>(
|
|
|
287
312
|
polygon={overlayPolygon}
|
|
288
313
|
/>
|
|
289
314
|
)}
|
|
290
|
-
{!autoCapture &&
|
|
315
|
+
{(showManualCaptureButton || !autoCapture) && (
|
|
316
|
+
<TouchableOpacity style={styles.button} onPress={handleManualCapture} />
|
|
317
|
+
)}
|
|
291
318
|
{children}
|
|
292
319
|
</View>
|
|
293
320
|
);
|
package/src/FullDocScanner.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
ActivityIndicator,
|
|
4
4
|
Alert,
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
import { DocScanner } from './DocScanner';
|
|
13
13
|
import { CropEditor } from './CropEditor';
|
|
14
14
|
import type { CapturedDocument, Point, Quad, Rectangle } from './types';
|
|
15
|
-
import type { DetectionConfig } from './DocScanner';
|
|
15
|
+
import type { DetectionConfig, DocScannerHandle, DocScannerCapture } from './DocScanner';
|
|
16
16
|
import { quadToRectangle, scaleRectangle } from './utils/coordinate';
|
|
17
17
|
|
|
18
18
|
type CustomCropManagerType = {
|
|
@@ -34,6 +34,39 @@ const stripFileUri = (value: string) => value.replace(/^file:\/\//, '');
|
|
|
34
34
|
|
|
35
35
|
const ensureFileUri = (value: string) => (value.startsWith('file://') ? value : `file://${value}`);
|
|
36
36
|
|
|
37
|
+
const createFullImageRectangle = (width: number, height: number): Rectangle => ({
|
|
38
|
+
topLeft: { x: 0, y: 0 },
|
|
39
|
+
topRight: { x: width, y: 0 },
|
|
40
|
+
bottomRight: { x: width, y: height },
|
|
41
|
+
bottomLeft: { x: 0, y: height },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const resolveImageSize = (
|
|
45
|
+
path: string,
|
|
46
|
+
fallbackWidth: number,
|
|
47
|
+
fallbackHeight: number,
|
|
48
|
+
): Promise<{ width: number; height: number }> =>
|
|
49
|
+
new Promise((resolve) => {
|
|
50
|
+
Image.getSize(
|
|
51
|
+
ensureFileUri(path),
|
|
52
|
+
(width, height) => resolve({ width, height }),
|
|
53
|
+
() => resolve({
|
|
54
|
+
width: fallbackWidth > 0 ? fallbackWidth : 0,
|
|
55
|
+
height: fallbackHeight > 0 ? fallbackHeight : 0,
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const normalizeCapturedDocument = (document: DocScannerCapture): CapturedDocument => {
|
|
61
|
+
const normalizedPath = stripFileUri(document.initialPath ?? document.path);
|
|
62
|
+
return {
|
|
63
|
+
...document,
|
|
64
|
+
path: normalizedPath,
|
|
65
|
+
initialPath: document.initialPath ? stripFileUri(document.initialPath) : normalizedPath,
|
|
66
|
+
croppedPath: document.croppedPath ? stripFileUri(document.croppedPath) : null,
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
|
|
37
70
|
export interface FullDocScannerResult {
|
|
38
71
|
original: CapturedDocument;
|
|
39
72
|
rectangle: Rectangle | null;
|
|
@@ -90,6 +123,10 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
90
123
|
const [imageSize, setImageSize] = useState<{ width: number; height: number } | null>(null);
|
|
91
124
|
const [processing, setProcessing] = useState(false);
|
|
92
125
|
const resolvedGridColor = gridColor ?? overlayColor;
|
|
126
|
+
const docScannerRef = useRef<DocScannerHandle | null>(null);
|
|
127
|
+
const manualCapturePending = useRef(false);
|
|
128
|
+
const processingCaptureRef = useRef(false);
|
|
129
|
+
const cropInitializedRef = useRef(false);
|
|
93
130
|
|
|
94
131
|
const mergedStrings = useMemo<Required<FullDocScannerStrings>>(
|
|
95
132
|
() => ({
|
|
@@ -117,7 +154,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
117
154
|
}, [capturedDoc]);
|
|
118
155
|
|
|
119
156
|
useEffect(() => {
|
|
120
|
-
if (!capturedDoc || !imageSize ||
|
|
157
|
+
if (!capturedDoc || !imageSize || cropInitializedRef.current) {
|
|
121
158
|
return;
|
|
122
159
|
}
|
|
123
160
|
|
|
@@ -148,9 +185,10 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
148
185
|
}
|
|
149
186
|
|
|
150
187
|
if (initialRectangle) {
|
|
188
|
+
cropInitializedRef.current = true;
|
|
151
189
|
setCropRectangle(initialRectangle);
|
|
152
190
|
}
|
|
153
|
-
}, [capturedDoc, imageSize
|
|
191
|
+
}, [capturedDoc, imageSize]);
|
|
154
192
|
|
|
155
193
|
const resetState = useCallback(() => {
|
|
156
194
|
setScreen('scanner');
|
|
@@ -158,35 +196,9 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
158
196
|
setCropRectangle(null);
|
|
159
197
|
setImageSize(null);
|
|
160
198
|
setProcessing(false);
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
(document: CapturedDocument) => {
|
|
165
|
-
const normalizedPath = stripFileUri(document.path);
|
|
166
|
-
const nextQuad = document.quad && document.quad.length === 4 ? (document.quad as Quad) : null;
|
|
167
|
-
const quadRectangle = nextQuad ? quadToRectangle(nextQuad) : null;
|
|
168
|
-
const nextRectangle = document.rectangle ?? quadRectangle ?? null;
|
|
169
|
-
const normalizedInitial =
|
|
170
|
-
document.initialPath != null ? stripFileUri(document.initialPath) : normalizedPath;
|
|
171
|
-
const normalizedCropped =
|
|
172
|
-
document.croppedPath != null ? stripFileUri(document.croppedPath) : null;
|
|
173
|
-
|
|
174
|
-
setCapturedDoc({
|
|
175
|
-
...document,
|
|
176
|
-
path: normalizedPath,
|
|
177
|
-
initialPath: normalizedInitial,
|
|
178
|
-
croppedPath: normalizedCropped,
|
|
179
|
-
quad: nextQuad,
|
|
180
|
-
rectangle: nextRectangle,
|
|
181
|
-
});
|
|
182
|
-
setCropRectangle(null);
|
|
183
|
-
setScreen('crop');
|
|
184
|
-
},
|
|
185
|
-
[],
|
|
186
|
-
);
|
|
187
|
-
|
|
188
|
-
const handleCropChange = useCallback((rectangle: Rectangle) => {
|
|
189
|
-
setCropRectangle(rectangle);
|
|
199
|
+
manualCapturePending.current = false;
|
|
200
|
+
processingCaptureRef.current = false;
|
|
201
|
+
cropInitializedRef.current = false;
|
|
190
202
|
}, []);
|
|
191
203
|
|
|
192
204
|
const emitError = useCallback(
|
|
@@ -200,7 +212,142 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
200
212
|
[onError],
|
|
201
213
|
);
|
|
202
214
|
|
|
203
|
-
const
|
|
215
|
+
const processAutoCapture = useCallback(
|
|
216
|
+
async (document: DocScannerCapture) => {
|
|
217
|
+
manualCapturePending.current = false;
|
|
218
|
+
const normalizedDoc = normalizeCapturedDocument(document);
|
|
219
|
+
const cropManager = NativeModules.CustomCropManager as CustomCropManagerType | undefined;
|
|
220
|
+
|
|
221
|
+
if (!cropManager?.crop) {
|
|
222
|
+
emitError(new Error('CustomCropManager.crop is not available'));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
setProcessing(true);
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
const size = await resolveImageSize(
|
|
230
|
+
normalizedDoc.path,
|
|
231
|
+
normalizedDoc.width,
|
|
232
|
+
normalizedDoc.height,
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
const targetWidthRaw = size.width > 0 ? size.width : normalizedDoc.width;
|
|
236
|
+
const targetHeightRaw = size.height > 0 ? size.height : normalizedDoc.height;
|
|
237
|
+
const baseWidth = normalizedDoc.width > 0 ? normalizedDoc.width : targetWidthRaw;
|
|
238
|
+
const baseHeight = normalizedDoc.height > 0 ? normalizedDoc.height : targetHeightRaw;
|
|
239
|
+
const targetWidth = targetWidthRaw > 0 ? targetWidthRaw : baseWidth || 1;
|
|
240
|
+
const targetHeight = targetHeightRaw > 0 ? targetHeightRaw : baseHeight || 1;
|
|
241
|
+
|
|
242
|
+
let rectangleBase: Rectangle | null = normalizedDoc.rectangle ?? null;
|
|
243
|
+
if (!rectangleBase && normalizedDoc.quad && normalizedDoc.quad.length === 4) {
|
|
244
|
+
rectangleBase = quadToRectangle(normalizedDoc.quad as Quad);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const scaledRectangle = rectangleBase
|
|
248
|
+
? scaleRectangle(
|
|
249
|
+
rectangleBase,
|
|
250
|
+
baseWidth || targetWidth,
|
|
251
|
+
baseHeight || targetHeight,
|
|
252
|
+
targetWidth,
|
|
253
|
+
targetHeight,
|
|
254
|
+
)
|
|
255
|
+
: null;
|
|
256
|
+
|
|
257
|
+
const rectangleToUse = scaledRectangle ?? createFullImageRectangle(targetWidth, targetHeight);
|
|
258
|
+
|
|
259
|
+
const base64 = await new Promise<string>((resolve, reject) => {
|
|
260
|
+
cropManager.crop(
|
|
261
|
+
{
|
|
262
|
+
topLeft: rectangleToUse.topLeft,
|
|
263
|
+
topRight: rectangleToUse.topRight,
|
|
264
|
+
bottomRight: rectangleToUse.bottomRight,
|
|
265
|
+
bottomLeft: rectangleToUse.bottomLeft,
|
|
266
|
+
width: targetWidth,
|
|
267
|
+
height: targetHeight,
|
|
268
|
+
},
|
|
269
|
+
ensureFileUri(normalizedDoc.path),
|
|
270
|
+
(error: unknown, result: { image: string }) => {
|
|
271
|
+
if (error) {
|
|
272
|
+
reject(error instanceof Error ? error : new Error('Crop failed'));
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
resolve(result.image);
|
|
276
|
+
},
|
|
277
|
+
);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const finalDoc: CapturedDocument = {
|
|
281
|
+
...normalizedDoc,
|
|
282
|
+
rectangle: rectangleToUse,
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
onResult({
|
|
286
|
+
original: finalDoc,
|
|
287
|
+
rectangle: rectangleToUse,
|
|
288
|
+
base64,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
resetState();
|
|
292
|
+
} catch (error) {
|
|
293
|
+
setProcessing(false);
|
|
294
|
+
emitError(error instanceof Error ? error : new Error(String(error)), 'Failed to process document.');
|
|
295
|
+
} finally {
|
|
296
|
+
processingCaptureRef.current = false;
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
[emitError, onResult, resetState],
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
const handleCapture = useCallback(
|
|
303
|
+
(document: DocScannerCapture) => {
|
|
304
|
+
if (processingCaptureRef.current) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const isManualCapture =
|
|
309
|
+
manualCapture || manualCapturePending.current || document.origin === 'manual';
|
|
310
|
+
|
|
311
|
+
const normalizedDoc = normalizeCapturedDocument(document);
|
|
312
|
+
|
|
313
|
+
if (isManualCapture) {
|
|
314
|
+
manualCapturePending.current = false;
|
|
315
|
+
processingCaptureRef.current = false;
|
|
316
|
+
cropInitializedRef.current = false;
|
|
317
|
+
setCapturedDoc(normalizedDoc);
|
|
318
|
+
setImageSize(null);
|
|
319
|
+
setCropRectangle(null);
|
|
320
|
+
setScreen('crop');
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
processingCaptureRef.current = true;
|
|
325
|
+
processAutoCapture(document);
|
|
326
|
+
},
|
|
327
|
+
[manualCapture, processAutoCapture],
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
const handleCropChange = useCallback((rectangle: Rectangle) => {
|
|
331
|
+
setCropRectangle(rectangle);
|
|
332
|
+
}, []);
|
|
333
|
+
|
|
334
|
+
const triggerManualCapture = useCallback(() => {
|
|
335
|
+
if (processingCaptureRef.current) {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
manualCapturePending.current = true;
|
|
339
|
+
const capturePromise = docScannerRef.current?.capture();
|
|
340
|
+
if (capturePromise && typeof capturePromise.catch === 'function') {
|
|
341
|
+
capturePromise.catch((error: unknown) => {
|
|
342
|
+
manualCapturePending.current = false;
|
|
343
|
+
console.warn('[FullDocScanner] manual capture failed', error);
|
|
344
|
+
});
|
|
345
|
+
} else if (!capturePromise) {
|
|
346
|
+
manualCapturePending.current = false;
|
|
347
|
+
}
|
|
348
|
+
}, []);
|
|
349
|
+
|
|
350
|
+
const performCrop = useCallback(async (): Promise<{ base64: string; rectangle: Rectangle }> => {
|
|
204
351
|
if (!capturedDoc) {
|
|
205
352
|
throw new Error('No captured document to crop');
|
|
206
353
|
}
|
|
@@ -214,43 +361,43 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
214
361
|
|
|
215
362
|
const baseWidth = capturedDoc.width > 0 ? capturedDoc.width : size.width;
|
|
216
363
|
const baseHeight = capturedDoc.height > 0 ? capturedDoc.height : size.height;
|
|
364
|
+
const targetWidth = size.width > 0 ? size.width : baseWidth || 1;
|
|
365
|
+
const targetHeight = size.height > 0 ? size.height : baseHeight || 1;
|
|
217
366
|
|
|
218
|
-
|
|
219
|
-
? scaleRectangle(
|
|
220
|
-
capturedDoc.rectangle,
|
|
221
|
-
baseWidth,
|
|
222
|
-
baseHeight,
|
|
223
|
-
size.width,
|
|
224
|
-
size.height,
|
|
225
|
-
)
|
|
226
|
-
: null;
|
|
227
|
-
|
|
228
|
-
let fallbackRectangle: Rectangle | null = rectangleFromDetection;
|
|
367
|
+
let fallbackRectangle: Rectangle | null = null;
|
|
229
368
|
|
|
230
|
-
if (
|
|
369
|
+
if (capturedDoc.rectangle) {
|
|
370
|
+
fallbackRectangle = scaleRectangle(
|
|
371
|
+
capturedDoc.rectangle,
|
|
372
|
+
baseWidth || targetWidth,
|
|
373
|
+
baseHeight || targetHeight,
|
|
374
|
+
targetWidth,
|
|
375
|
+
targetHeight,
|
|
376
|
+
);
|
|
377
|
+
} else if (capturedDoc.quad && capturedDoc.quad.length === 4) {
|
|
231
378
|
const quadRectangle = quadToRectangle(capturedDoc.quad as Quad);
|
|
232
379
|
if (quadRectangle) {
|
|
233
380
|
fallbackRectangle = scaleRectangle(
|
|
234
381
|
quadRectangle,
|
|
235
|
-
baseWidth,
|
|
236
|
-
baseHeight,
|
|
237
|
-
|
|
238
|
-
|
|
382
|
+
baseWidth || targetWidth,
|
|
383
|
+
baseHeight || targetHeight,
|
|
384
|
+
targetWidth,
|
|
385
|
+
targetHeight,
|
|
239
386
|
);
|
|
240
387
|
}
|
|
241
388
|
}
|
|
242
389
|
|
|
243
|
-
const
|
|
390
|
+
const rectangleToUse = cropRectangle ?? fallbackRectangle ?? createFullImageRectangle(targetWidth, targetHeight);
|
|
244
391
|
|
|
245
392
|
const base64 = await new Promise<string>((resolve, reject) => {
|
|
246
393
|
cropManager.crop(
|
|
247
394
|
{
|
|
248
|
-
topLeft:
|
|
249
|
-
topRight:
|
|
250
|
-
bottomRight:
|
|
251
|
-
bottomLeft:
|
|
252
|
-
width:
|
|
253
|
-
height:
|
|
395
|
+
topLeft: rectangleToUse.topLeft,
|
|
396
|
+
topRight: rectangleToUse.topRight,
|
|
397
|
+
bottomRight: rectangleToUse.bottomRight,
|
|
398
|
+
bottomLeft: rectangleToUse.bottomLeft,
|
|
399
|
+
width: targetWidth,
|
|
400
|
+
height: targetHeight,
|
|
254
401
|
},
|
|
255
402
|
ensureFileUri(capturedDoc.path),
|
|
256
403
|
(error: unknown, result: { image: string }) => {
|
|
@@ -264,7 +411,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
264
411
|
);
|
|
265
412
|
});
|
|
266
413
|
|
|
267
|
-
return base64;
|
|
414
|
+
return { base64, rectangle: rectangleToUse };
|
|
268
415
|
}, [capturedDoc, cropRectangle, imageSize]);
|
|
269
416
|
|
|
270
417
|
const handleConfirm = useCallback(async () => {
|
|
@@ -274,11 +421,15 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
274
421
|
|
|
275
422
|
try {
|
|
276
423
|
setProcessing(true);
|
|
277
|
-
const base64 = await performCrop();
|
|
424
|
+
const { base64, rectangle } = await performCrop();
|
|
278
425
|
setProcessing(false);
|
|
426
|
+
const finalDoc: CapturedDocument = {
|
|
427
|
+
...capturedDoc,
|
|
428
|
+
rectangle,
|
|
429
|
+
};
|
|
279
430
|
onResult({
|
|
280
|
-
original:
|
|
281
|
-
rectangle
|
|
431
|
+
original: finalDoc,
|
|
432
|
+
rectangle,
|
|
282
433
|
base64,
|
|
283
434
|
});
|
|
284
435
|
resetState();
|
|
@@ -286,7 +437,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
286
437
|
setProcessing(false);
|
|
287
438
|
emitError(error instanceof Error ? error : new Error(String(error)), 'Failed to process document.');
|
|
288
439
|
}
|
|
289
|
-
}, [capturedDoc,
|
|
440
|
+
}, [capturedDoc, emitError, onResult, performCrop, resetState]);
|
|
290
441
|
|
|
291
442
|
const handleRetake = useCallback(() => {
|
|
292
443
|
resetState();
|
|
@@ -302,6 +453,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
302
453
|
{screen === 'scanner' && (
|
|
303
454
|
<View style={styles.flex}>
|
|
304
455
|
<DocScanner
|
|
456
|
+
ref={docScannerRef}
|
|
305
457
|
autoCapture={!manualCapture}
|
|
306
458
|
overlayColor={overlayColor}
|
|
307
459
|
showGrid={showGrid}
|
|
@@ -322,8 +474,17 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
322
474
|
</TouchableOpacity>
|
|
323
475
|
<View style={styles.instructions} pointerEvents="none">
|
|
324
476
|
<Text style={styles.captureText}>{mergedStrings.captureHint}</Text>
|
|
325
|
-
|
|
477
|
+
<Text style={styles.captureText}>{mergedStrings.manualHint}</Text>
|
|
326
478
|
</View>
|
|
479
|
+
<TouchableOpacity
|
|
480
|
+
style={[styles.shutterButton, processing && styles.shutterButtonDisabled]}
|
|
481
|
+
onPress={triggerManualCapture}
|
|
482
|
+
disabled={processing}
|
|
483
|
+
accessibilityLabel={mergedStrings.manualHint}
|
|
484
|
+
accessibilityRole="button"
|
|
485
|
+
>
|
|
486
|
+
<View style={styles.shutterInner} />
|
|
487
|
+
</TouchableOpacity>
|
|
327
488
|
</View>
|
|
328
489
|
</DocScanner>
|
|
329
490
|
</View>
|
|
@@ -401,6 +562,26 @@ const styles = StyleSheet.create({
|
|
|
401
562
|
fontSize: 15,
|
|
402
563
|
textAlign: 'center',
|
|
403
564
|
},
|
|
565
|
+
shutterButton: {
|
|
566
|
+
alignSelf: 'center',
|
|
567
|
+
width: 80,
|
|
568
|
+
height: 80,
|
|
569
|
+
borderRadius: 40,
|
|
570
|
+
borderWidth: 4,
|
|
571
|
+
borderColor: '#fff',
|
|
572
|
+
justifyContent: 'center',
|
|
573
|
+
alignItems: 'center',
|
|
574
|
+
backgroundColor: 'rgba(255,255,255,0.1)',
|
|
575
|
+
},
|
|
576
|
+
shutterButtonDisabled: {
|
|
577
|
+
opacity: 0.4,
|
|
578
|
+
},
|
|
579
|
+
shutterInner: {
|
|
580
|
+
width: 60,
|
|
581
|
+
height: 60,
|
|
582
|
+
borderRadius: 30,
|
|
583
|
+
backgroundColor: '#fff',
|
|
584
|
+
},
|
|
404
585
|
cropFooter: {
|
|
405
586
|
position: 'absolute',
|
|
406
587
|
bottom: 40,
|