react-native-rectangle-doc-scanner 3.29.0 → 3.31.0

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