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.
@@ -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
- : null;
96
- if (!sourceRectangle) {
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) => {
@@ -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>;
@@ -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
- const result = instance.capture();
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.then((payload) => {
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 = { resolve, reject };
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
- if (autoCapture) {
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
- }, [autoCapture, capture]);
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({
@@ -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 || cropRectangle) {
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
- if (initialRectangle) {
83
- setCropRectangle(initialRectangle);
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
- const handleCapture = (0, react_1.useCallback)((document) => {
94
- const normalizedPath = stripFileUri(document.path);
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 rectangleFromDetection = capturedDoc.rectangle
133
- ? (0, coordinate_1.scaleRectangle)(capturedDoc.rectangle, baseWidth, baseHeight, size.width, size.height)
134
- : null;
135
- let fallbackRectangle = rectangleFromDetection;
136
- if (!fallbackRectangle && capturedDoc.quad && capturedDoc.quad.length === 4) {
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, size.width, size.height);
240
+ fallbackRectangle = (0, coordinate_1.scaleRectangle)(quadRectangle, baseWidth || targetWidth, baseHeight || targetHeight, targetWidth, targetHeight);
140
241
  }
141
242
  }
142
- const rectangle = cropRectangle ?? fallbackRectangle;
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: rectangle?.topLeft ?? { x: 0, y: 0 },
146
- topRight: rectangle?.topRight ?? { x: size.width, y: 0 },
147
- bottomRight: rectangle?.bottomRight ?? { x: size.width, y: size.height },
148
- bottomLeft: rectangle?.bottomLeft ?? { x: 0, y: size.height },
149
- width: size.width,
150
- height: size.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: capturedDoc,
171
- rectangle: cropRectangle,
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, cropRectangle, emitError, onResult, performCrop, resetState]);
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
- manualCapture && react_1.default.createElement(react_native_1.Text, { style: styles.captureText }, mergedStrings.manualHint)))))),
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
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-rectangle-doc-scanner",
3
- "version": "3.29.0",
3
+ "version": "3.33.0",
4
4
  "description": "Native-backed document scanner for React Native with customizable overlays.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -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
- : null;
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,
@@ -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
- const result = instance.capture();
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.then((payload: PictureEvent) => {
200
- handlePictureTaken(payload);
201
- return payload;
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 = { resolve, reject };
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
- if (autoCapture) {
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
- }, [autoCapture, capture]);
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 && <TouchableOpacity style={styles.button} onPress={handleManualCapture} />}
320
+ {(showManualCaptureButton || !autoCapture) && (
321
+ <TouchableOpacity style={styles.button} onPress={handleManualCapture} />
322
+ )}
291
323
  {children}
292
324
  </View>
293
325
  );
@@ -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 || cropRectangle) {
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
- if (initialRectangle) {
151
- setCropRectangle(initialRectangle);
152
- }
153
- }, [capturedDoc, imageSize, cropRectangle]);
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
- const handleCapture = useCallback(
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 performCrop = useCallback(async (): Promise<string> => {
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
- const rectangleFromDetection = capturedDoc.rectangle
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 (!fallbackRectangle && capturedDoc.quad && capturedDoc.quad.length === 4) {
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
- size.width,
238
- size.height,
376
+ baseWidth || targetWidth,
377
+ baseHeight || targetHeight,
378
+ targetWidth,
379
+ targetHeight,
239
380
  );
240
381
  }
241
382
  }
242
383
 
243
- const rectangle = cropRectangle ?? fallbackRectangle;
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: rectangle?.topLeft ?? { x: 0, y: 0 },
249
- topRight: rectangle?.topRight ?? { x: size.width, y: 0 },
250
- bottomRight: rectangle?.bottomRight ?? { x: size.width, y: size.height },
251
- bottomLeft: rectangle?.bottomLeft ?? { x: 0, y: size.height },
252
- width: size.width,
253
- height: size.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: capturedDoc,
281
- rectangle: cropRectangle,
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, cropRectangle, emitError, onResult, performCrop, resetState]);
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
- {manualCapture && <Text style={styles.captureText}>{mergedStrings.manualHint}</Text>}
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
@@ -24,4 +24,5 @@ export {
24
24
  rectangleToQuad,
25
25
  scaleCoordinates,
26
26
  scaleRectangle,
27
+ createFullImageRectangle,
27
28
  } from './utils/coordinate';
@@ -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