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