react-native-rectangle-doc-scanner 3.27.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.
@@ -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
  () => ({
@@ -116,38 +153,52 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
116
153
  );
117
154
  }, [capturedDoc]);
118
155
 
156
+ useEffect(() => {
157
+ if (!capturedDoc || !imageSize || cropInitializedRef.current) {
158
+ return;
159
+ }
160
+
161
+ const baseWidth = capturedDoc.width > 0 ? capturedDoc.width : imageSize.width;
162
+ const baseHeight = capturedDoc.height > 0 ? capturedDoc.height : imageSize.height;
163
+
164
+ let initialRectangle: Rectangle | null = null;
165
+
166
+ if (capturedDoc.rectangle) {
167
+ initialRectangle = scaleRectangle(
168
+ capturedDoc.rectangle,
169
+ baseWidth,
170
+ baseHeight,
171
+ imageSize.width,
172
+ imageSize.height,
173
+ );
174
+ } else if (capturedDoc.quad && capturedDoc.quad.length === 4) {
175
+ const quadRectangle = quadToRectangle(capturedDoc.quad as Quad);
176
+ if (quadRectangle) {
177
+ initialRectangle = scaleRectangle(
178
+ quadRectangle,
179
+ baseWidth,
180
+ baseHeight,
181
+ imageSize.width,
182
+ imageSize.height,
183
+ );
184
+ }
185
+ }
186
+
187
+ if (initialRectangle) {
188
+ cropInitializedRef.current = true;
189
+ setCropRectangle(initialRectangle);
190
+ }
191
+ }, [capturedDoc, imageSize]);
192
+
119
193
  const resetState = useCallback(() => {
120
194
  setScreen('scanner');
121
195
  setCapturedDoc(null);
122
196
  setCropRectangle(null);
123
197
  setImageSize(null);
124
198
  setProcessing(false);
125
- }, []);
126
-
127
- const handleCapture = useCallback(
128
- (document: CapturedDocument) => {
129
- const normalizedPath = stripFileUri(document.path);
130
- const nextQuad = document.quad && document.quad.length === 4 ? (document.quad as Quad) : null;
131
- const normalizedInitial =
132
- document.initialPath != null ? stripFileUri(document.initialPath) : normalizedPath;
133
- const normalizedCropped =
134
- document.croppedPath != null ? stripFileUri(document.croppedPath) : null;
135
-
136
- setCapturedDoc({
137
- ...document,
138
- path: normalizedPath,
139
- initialPath: normalizedInitial,
140
- croppedPath: normalizedCropped,
141
- quad: nextQuad,
142
- });
143
- setCropRectangle(nextQuad ? quadToRectangle(nextQuad) : null);
144
- setScreen('crop');
145
- },
146
- [],
147
- );
148
-
149
- const handleCropChange = useCallback((rectangle: Rectangle) => {
150
- setCropRectangle(rectangle);
199
+ manualCapturePending.current = false;
200
+ processingCaptureRef.current = false;
201
+ cropInitializedRef.current = false;
151
202
  }, []);
152
203
 
153
204
  const emitError = useCallback(
@@ -161,7 +212,142 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
161
212
  [onError],
162
213
  );
163
214
 
164
- 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 }> => {
165
351
  if (!capturedDoc) {
166
352
  throw new Error('No captured document to crop');
167
353
  }
@@ -173,32 +359,45 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
173
359
  throw new Error('CustomCropManager.crop is not available');
174
360
  }
175
361
 
176
- const fallbackRectangle =
177
- capturedDoc.quad && capturedDoc.quad.length === 4
178
- ? quadToRectangle(capturedDoc.quad as Quad)
179
- : null;
362
+ const baseWidth = capturedDoc.width > 0 ? capturedDoc.width : size.width;
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;
180
366
 
181
- const scaledFallback = fallbackRectangle
182
- ? scaleRectangle(
183
- fallbackRectangle,
184
- capturedDoc.width,
185
- capturedDoc.height,
186
- size.width,
187
- size.height,
188
- )
189
- : null;
367
+ let fallbackRectangle: Rectangle | null = null;
190
368
 
191
- const rectangle = cropRectangle ?? scaledFallback;
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) {
378
+ const quadRectangle = quadToRectangle(capturedDoc.quad as Quad);
379
+ if (quadRectangle) {
380
+ fallbackRectangle = scaleRectangle(
381
+ quadRectangle,
382
+ baseWidth || targetWidth,
383
+ baseHeight || targetHeight,
384
+ targetWidth,
385
+ targetHeight,
386
+ );
387
+ }
388
+ }
389
+
390
+ const rectangleToUse = cropRectangle ?? fallbackRectangle ?? createFullImageRectangle(targetWidth, targetHeight);
192
391
 
193
392
  const base64 = await new Promise<string>((resolve, reject) => {
194
393
  cropManager.crop(
195
394
  {
196
- topLeft: rectangle?.topLeft ?? { x: 0, y: 0 },
197
- topRight: rectangle?.topRight ?? { x: size.width, y: 0 },
198
- bottomRight: rectangle?.bottomRight ?? { x: size.width, y: size.height },
199
- bottomLeft: rectangle?.bottomLeft ?? { x: 0, y: size.height },
200
- width: size.width,
201
- 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,
202
401
  },
203
402
  ensureFileUri(capturedDoc.path),
204
403
  (error: unknown, result: { image: string }) => {
@@ -212,7 +411,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
212
411
  );
213
412
  });
214
413
 
215
- return base64;
414
+ return { base64, rectangle: rectangleToUse };
216
415
  }, [capturedDoc, cropRectangle, imageSize]);
217
416
 
218
417
  const handleConfirm = useCallback(async () => {
@@ -222,11 +421,15 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
222
421
 
223
422
  try {
224
423
  setProcessing(true);
225
- const base64 = await performCrop();
424
+ const { base64, rectangle } = await performCrop();
226
425
  setProcessing(false);
426
+ const finalDoc: CapturedDocument = {
427
+ ...capturedDoc,
428
+ rectangle,
429
+ };
227
430
  onResult({
228
- original: capturedDoc,
229
- rectangle: cropRectangle,
431
+ original: finalDoc,
432
+ rectangle,
230
433
  base64,
231
434
  });
232
435
  resetState();
@@ -234,7 +437,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
234
437
  setProcessing(false);
235
438
  emitError(error instanceof Error ? error : new Error(String(error)), 'Failed to process document.');
236
439
  }
237
- }, [capturedDoc, cropRectangle, emitError, onResult, performCrop, resetState]);
440
+ }, [capturedDoc, emitError, onResult, performCrop, resetState]);
238
441
 
239
442
  const handleRetake = useCallback(() => {
240
443
  resetState();
@@ -250,6 +453,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
250
453
  {screen === 'scanner' && (
251
454
  <View style={styles.flex}>
252
455
  <DocScanner
456
+ ref={docScannerRef}
253
457
  autoCapture={!manualCapture}
254
458
  overlayColor={overlayColor}
255
459
  showGrid={showGrid}
@@ -270,8 +474,17 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
270
474
  </TouchableOpacity>
271
475
  <View style={styles.instructions} pointerEvents="none">
272
476
  <Text style={styles.captureText}>{mergedStrings.captureHint}</Text>
273
- {manualCapture && <Text style={styles.captureText}>{mergedStrings.manualHint}</Text>}
477
+ <Text style={styles.captureText}>{mergedStrings.manualHint}</Text>
274
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>
275
488
  </View>
276
489
  </DocScanner>
277
490
  </View>
@@ -349,6 +562,26 @@ const styles = StyleSheet.create({
349
562
  fontSize: 15,
350
563
  textAlign: 'center',
351
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
+ },
352
585
  cropFooter: {
353
586
  position: 'absolute',
354
587
  bottom: 40,
package/src/types.ts CHANGED
@@ -14,6 +14,7 @@ export type CapturedDocument = {
14
14
  initialPath?: string | null;
15
15
  croppedPath?: string | null;
16
16
  quad: Point[] | null;
17
+ rectangle?: Rectangle | null;
17
18
  width: number;
18
19
  height: number;
19
20
  };