react-native-rectangle-doc-scanner 3.91.0 → 3.93.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.
@@ -76,11 +76,20 @@ exports.DocScanner = (0, react_1.forwardRef)(({ onCapture, overlayColor = DEFAUL
76
76
  const [detectedRectangle, setDetectedRectangle] = (0, react_1.useState)(null);
77
77
  const lastRectangleRef = (0, react_1.useRef)(null);
78
78
  const captureOriginRef = (0, react_1.useRef)('auto');
79
+ const rectangleClearTimeoutRef = (0, react_1.useRef)(null);
79
80
  (0, react_1.useEffect)(() => {
80
81
  if (!autoCapture) {
81
82
  setIsAutoCapturing(false);
82
83
  }
83
84
  }, [autoCapture]);
85
+ // Cleanup timeout on unmount
86
+ (0, react_1.useEffect)(() => {
87
+ return () => {
88
+ if (rectangleClearTimeoutRef.current) {
89
+ clearTimeout(rectangleClearTimeoutRef.current);
90
+ }
91
+ };
92
+ }, []);
84
93
  const normalizedQuality = (0, react_1.useMemo)(() => {
85
94
  if (react_native_1.Platform.OS === 'ios') {
86
95
  // iOS expects 0-1
@@ -281,7 +290,28 @@ exports.DocScanner = (0, react_1.forwardRef)(({ onCapture, overlayColor = DEFAUL
281
290
  lastRectangleRef.current = payload.rectangleCoordinates;
282
291
  }
283
292
  const isGoodRectangle = payload.lastDetectionType === 0;
284
- setDetectedRectangle(isGoodRectangle && rectangleOnScreen ? payload : null);
293
+ const hasValidRectangle = isGoodRectangle && rectangleOnScreen;
294
+ // 그리드를 표시할 조건: 좋은 품질의 사각형이 화면에 있을 때만
295
+ if (hasValidRectangle) {
296
+ // 기존 타임아웃 클리어
297
+ if (rectangleClearTimeoutRef.current) {
298
+ clearTimeout(rectangleClearTimeoutRef.current);
299
+ }
300
+ setDetectedRectangle(payload);
301
+ // 500ms 후에 그리드 자동 클리어 (새로운 이벤트가 없으면)
302
+ rectangleClearTimeoutRef.current = setTimeout(() => {
303
+ setDetectedRectangle(null);
304
+ rectangleClearTimeoutRef.current = null;
305
+ }, 500);
306
+ }
307
+ else {
308
+ // 즉시 클리어
309
+ if (rectangleClearTimeoutRef.current) {
310
+ clearTimeout(rectangleClearTimeoutRef.current);
311
+ rectangleClearTimeoutRef.current = null;
312
+ }
313
+ setDetectedRectangle(null);
314
+ }
285
315
  onRectangleDetect?.(payload);
286
316
  }, [autoCapture, minStableFrames, onRectangleDetect]);
287
317
  (0, react_1.useImperativeHandle)(ref, () => ({
@@ -110,7 +110,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
110
110
  const [rectangleDetected, setRectangleDetected] = (0, react_1.useState)(false);
111
111
  const [rectangleHint, setRectangleHint] = (0, react_1.useState)(false);
112
112
  const [flashEnabled, setFlashEnabled] = (0, react_1.useState)(false);
113
- const [isRotating, setIsRotating] = (0, react_1.useState)(false);
113
+ const [rotationDegrees, setRotationDegrees] = (0, react_1.useState)(0);
114
114
  const resolvedGridColor = gridColor ?? overlayColor;
115
115
  const docScannerRef = (0, react_1.useRef)(null);
116
116
  const captureModeRef = (0, react_1.useRef)(null);
@@ -329,54 +329,57 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
329
329
  const handleFlashToggle = (0, react_1.useCallback)(() => {
330
330
  setFlashEnabled(prev => !prev);
331
331
  }, []);
332
- const handleRotateImage = (0, react_1.useCallback)(async (degrees) => {
333
- if (isRotating || !croppedImageData)
332
+ const handleRotateImage = (0, react_1.useCallback)((degrees) => {
333
+ if (!croppedImageData)
334
334
  return;
335
- setIsRotating(true);
336
- try {
337
- console.log('[FullDocScanner] Rotating image by', degrees, 'degrees');
338
- const rotatedImage = await react_native_image_crop_picker_1.default.openCropper({
339
- path: croppedImageData.path,
340
- mediaType: 'photo',
341
- cropping: true,
342
- freeStyleCropEnabled: true,
343
- includeBase64: true,
344
- compressImageQuality: 0.9,
345
- cropperToolbarTitle: degrees === 90 ? '↻' : '↺',
346
- cropperChooseText: '완료',
347
- cropperCancelText: '취소',
348
- cropperRotateButtonsHidden: false,
349
- });
350
- console.log('[FullDocScanner] Image rotated successfully');
351
- setCroppedImageData({
352
- path: rotatedImage.path,
353
- base64: rotatedImage.data ?? undefined,
354
- });
355
- }
356
- catch (error) {
357
- console.error('[FullDocScanner] Image rotation error:', error);
358
- if (error && typeof error === 'object' && 'message' in error) {
359
- const errorMessage = error.message;
360
- if (!errorMessage.includes('cancel') && !errorMessage.includes('User cancelled')) {
361
- emitError(error instanceof Error ? error : new Error(String(error)), 'Failed to rotate image.');
362
- }
335
+ setRotationDegrees(prev => {
336
+ const newRotation = (prev + degrees + 360) % 360;
337
+ return newRotation;
338
+ });
339
+ }, [croppedImageData]);
340
+ const handleConfirm = (0, react_1.useCallback)(async () => {
341
+ if (!croppedImageData)
342
+ return;
343
+ // 회전이 필요한 경우 실제로 이미지를 회전
344
+ if (rotationDegrees !== 0) {
345
+ try {
346
+ const rotatedImage = await react_native_image_crop_picker_1.default.openCropper({
347
+ path: croppedImageData.path,
348
+ mediaType: 'photo',
349
+ cropping: true,
350
+ freeStyleCropEnabled: true,
351
+ includeBase64: true,
352
+ compressImageQuality: 0.9,
353
+ cropperToolbarTitle: ' ',
354
+ cropperChooseText: '완료',
355
+ cropperCancelText: '취소',
356
+ cropperRotateButtonsHidden: false,
357
+ });
358
+ onResult({
359
+ path: rotatedImage.path,
360
+ base64: rotatedImage.data ?? undefined,
361
+ });
362
+ }
363
+ catch (error) {
364
+ console.error('[FullDocScanner] Image rotation error:', error);
365
+ // 에러 발생 시 원본 이미지 전송
366
+ onResult({
367
+ path: croppedImageData.path,
368
+ base64: croppedImageData.base64,
369
+ });
363
370
  }
364
371
  }
365
- finally {
366
- setIsRotating(false);
367
- }
368
- }, [isRotating, croppedImageData, emitError]);
369
- const handleConfirm = (0, react_1.useCallback)(() => {
370
- if (croppedImageData) {
372
+ else {
371
373
  onResult({
372
374
  path: croppedImageData.path,
373
375
  base64: croppedImageData.base64,
374
376
  });
375
377
  }
376
- }, [croppedImageData, onResult]);
378
+ }, [croppedImageData, rotationDegrees, onResult]);
377
379
  const handleRetake = (0, react_1.useCallback)(() => {
378
380
  console.log('[FullDocScanner] Retake - clearing cropped image and resetting scanner');
379
381
  setCroppedImageData(null);
382
+ setRotationDegrees(0);
380
383
  setProcessing(false);
381
384
  setRectangleDetected(false);
382
385
  setRectangleHint(false);
@@ -422,30 +425,18 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
422
425
  }
423
426
  if (captureReady) {
424
427
  scheduleClear(rectangleCaptureTimeoutRef, () => {
425
- console.log('[FullDocScanner] Rectangle timeout - clearing detection');
426
428
  setRectangleDetected(false);
427
429
  });
428
430
  setRectangleDetected(true);
429
431
  }
430
- else if (!hasRectangle) {
432
+ else {
433
+ // 그리드가 없거나 품질이 좋지 않으면 즉시 상태 해제
431
434
  if (rectangleCaptureTimeoutRef.current) {
432
435
  clearTimeout(rectangleCaptureTimeoutRef.current);
433
436
  rectangleCaptureTimeoutRef.current = null;
434
437
  }
435
438
  setRectangleDetected(false);
436
439
  }
437
- else if (rectangleDetected) {
438
- scheduleClear(rectangleCaptureTimeoutRef, () => {
439
- console.log('[FullDocScanner] Rectangle timeout - clearing detection');
440
- setRectangleDetected(false);
441
- });
442
- }
443
- console.log('[FullDocScanner] Rectangle detection update', {
444
- lastDetectionType: event.lastDetectionType,
445
- stableCounter,
446
- hasRectangle,
447
- captureReady,
448
- });
449
440
  }, [rectangleDetected]);
450
441
  (0, react_1.useEffect)(() => () => {
451
442
  if (rectangleCaptureTimeoutRef.current) {
@@ -459,14 +450,17 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
459
450
  croppedImageData ? (
460
451
  // check_DP: Show confirmation screen
461
452
  react_1.default.createElement(react_native_1.View, { style: styles.confirmationContainer },
462
- react_1.default.createElement(react_native_1.View, { style: styles.rotateButtonsTop },
463
- react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.rotateButtonTop, isRotating && styles.buttonDisabled], onPress: () => handleRotateImage(-90), disabled: isRotating, accessibilityLabel: "\uC67C\uCABD\uC73C\uB85C 90\uB3C4 \uD68C\uC804", accessibilityRole: "button" },
453
+ react_1.default.createElement(react_native_1.View, { style: styles.rotateButtonsCenter },
454
+ react_1.default.createElement(react_native_1.TouchableOpacity, { style: styles.rotateButtonTop, onPress: () => handleRotateImage(-90), accessibilityLabel: "\uC67C\uCABD\uC73C\uB85C 90\uB3C4 \uD68C\uC804", accessibilityRole: "button" },
464
455
  react_1.default.createElement(react_native_1.Text, { style: styles.rotateIconText }, "\u21BA"),
465
456
  react_1.default.createElement(react_native_1.Text, { style: styles.rotateButtonLabel }, "\uC88C\uB85C 90\u00B0")),
466
- react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.rotateButtonTop, isRotating && styles.buttonDisabled], onPress: () => handleRotateImage(90), disabled: isRotating, accessibilityLabel: "\uC624\uB978\uCABD\uC73C\uB85C 90\uB3C4 \uD68C\uC804", accessibilityRole: "button" },
457
+ react_1.default.createElement(react_native_1.TouchableOpacity, { style: styles.rotateButtonTop, onPress: () => handleRotateImage(90), accessibilityLabel: "\uC624\uB978\uCABD\uC73C\uB85C 90\uB3C4 \uD68C\uC804", accessibilityRole: "button" },
467
458
  react_1.default.createElement(react_native_1.Text, { style: styles.rotateIconText }, "\u21BB"),
468
459
  react_1.default.createElement(react_native_1.Text, { style: styles.rotateButtonLabel }, "\uC6B0\uB85C 90\u00B0"))),
469
- react_1.default.createElement(react_native_1.Image, { source: { uri: croppedImageData.path }, style: styles.previewImage, resizeMode: "contain" }),
460
+ react_1.default.createElement(react_native_1.Image, { source: { uri: croppedImageData.path }, style: [
461
+ styles.previewImage,
462
+ { transform: [{ rotate: `${rotationDegrees}deg` }] }
463
+ ], resizeMode: "contain" }),
470
464
  react_1.default.createElement(react_native_1.View, { style: styles.confirmationButtons },
471
465
  react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.confirmButton, styles.retakeButton], onPress: handleRetake, accessibilityLabel: mergedStrings.retake, accessibilityRole: "button" },
472
466
  react_1.default.createElement(react_native_1.Text, { style: styles.confirmButtonText }, mergedStrings.retake)),
@@ -625,6 +619,17 @@ const styles = react_native_1.StyleSheet.create({
625
619
  gap: 12,
626
620
  zIndex: 10,
627
621
  },
622
+ rotateButtonsCenter: {
623
+ position: 'absolute',
624
+ top: 60,
625
+ left: 0,
626
+ right: 0,
627
+ flexDirection: 'row',
628
+ justifyContent: 'center',
629
+ alignItems: 'center',
630
+ gap: 12,
631
+ zIndex: 10,
632
+ },
628
633
  rotateButtonTop: {
629
634
  flexDirection: 'row',
630
635
  alignItems: 'center',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-rectangle-doc-scanner",
3
- "version": "3.91.0",
3
+ "version": "3.93.0",
4
4
  "description": "Native-backed document scanner for React Native with customizable overlays.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -142,6 +142,7 @@ export const DocScanner = forwardRef<DocScannerHandle, Props>(
142
142
  const [detectedRectangle, setDetectedRectangle] = useState<RectangleDetectEvent | null>(null);
143
143
  const lastRectangleRef = useRef<Rectangle | null>(null);
144
144
  const captureOriginRef = useRef<'auto' | 'manual'>('auto');
145
+ const rectangleClearTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
145
146
 
146
147
  useEffect(() => {
147
148
  if (!autoCapture) {
@@ -149,6 +150,15 @@ export const DocScanner = forwardRef<DocScannerHandle, Props>(
149
150
  }
150
151
  }, [autoCapture]);
151
152
 
153
+ // Cleanup timeout on unmount
154
+ useEffect(() => {
155
+ return () => {
156
+ if (rectangleClearTimeoutRef.current) {
157
+ clearTimeout(rectangleClearTimeoutRef.current);
158
+ }
159
+ };
160
+ }, []);
161
+
152
162
  const normalizedQuality = useMemo(() => {
153
163
  if (Platform.OS === 'ios') {
154
164
  // iOS expects 0-1
@@ -385,7 +395,29 @@ export const DocScanner = forwardRef<DocScannerHandle, Props>(
385
395
  }
386
396
 
387
397
  const isGoodRectangle = payload.lastDetectionType === 0;
388
- setDetectedRectangle(isGoodRectangle && rectangleOnScreen ? payload : null);
398
+ const hasValidRectangle = isGoodRectangle && rectangleOnScreen;
399
+
400
+ // 그리드를 표시할 조건: 좋은 품질의 사각형이 화면에 있을 때만
401
+ if (hasValidRectangle) {
402
+ // 기존 타임아웃 클리어
403
+ if (rectangleClearTimeoutRef.current) {
404
+ clearTimeout(rectangleClearTimeoutRef.current);
405
+ }
406
+ setDetectedRectangle(payload);
407
+ // 500ms 후에 그리드 자동 클리어 (새로운 이벤트가 없으면)
408
+ rectangleClearTimeoutRef.current = setTimeout(() => {
409
+ setDetectedRectangle(null);
410
+ rectangleClearTimeoutRef.current = null;
411
+ }, 500);
412
+ } else {
413
+ // 즉시 클리어
414
+ if (rectangleClearTimeoutRef.current) {
415
+ clearTimeout(rectangleClearTimeoutRef.current);
416
+ rectangleClearTimeoutRef.current = null;
417
+ }
418
+ setDetectedRectangle(null);
419
+ }
420
+
389
421
  onRectangleDetect?.(payload);
390
422
  },
391
423
  [autoCapture, minStableFrames, onRectangleDetect],
@@ -152,7 +152,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
152
152
  const [rectangleDetected, setRectangleDetected] = useState(false);
153
153
  const [rectangleHint, setRectangleHint] = useState(false);
154
154
  const [flashEnabled, setFlashEnabled] = useState(false);
155
- const [isRotating, setIsRotating] = useState(false);
155
+ const [rotationDegrees, setRotationDegrees] = useState(0);
156
156
  const resolvedGridColor = gridColor ?? overlayColor;
157
157
  const docScannerRef = useRef<DocScannerHandle | null>(null);
158
158
  const captureModeRef = useRef<'grid' | 'no-grid' | null>(null);
@@ -440,59 +440,58 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
440
440
  setFlashEnabled(prev => !prev);
441
441
  }, []);
442
442
 
443
- const handleRotateImage = useCallback(async (degrees: -90 | 90) => {
444
- if (isRotating || !croppedImageData) return;
443
+ const handleRotateImage = useCallback((degrees: -90 | 90) => {
444
+ if (!croppedImageData) return;
445
445
 
446
- setIsRotating(true);
447
- try {
448
- console.log('[FullDocScanner] Rotating image by', degrees, 'degrees');
446
+ setRotationDegrees(prev => {
447
+ const newRotation = (prev + degrees + 360) % 360;
448
+ return newRotation;
449
+ });
450
+ }, [croppedImageData]);
449
451
 
450
- const rotatedImage = await ImageCropPicker.openCropper({
451
- path: croppedImageData.path,
452
- mediaType: 'photo',
453
- cropping: true,
454
- freeStyleCropEnabled: true,
455
- includeBase64: true,
456
- compressImageQuality: 0.9,
457
- cropperToolbarTitle: degrees === 90 ? '↻' : '↺',
458
- cropperChooseText: '완료',
459
- cropperCancelText: '취소',
460
- cropperRotateButtonsHidden: false,
461
- });
452
+ const handleConfirm = useCallback(async () => {
453
+ if (!croppedImageData) return;
462
454
 
463
- console.log('[FullDocScanner] Image rotated successfully');
464
- setCroppedImageData({
465
- path: rotatedImage.path,
466
- base64: rotatedImage.data ?? undefined,
467
- });
468
- } catch (error) {
469
- console.error('[FullDocScanner] Image rotation error:', error);
470
- if (error && typeof error === 'object' && 'message' in error) {
471
- const errorMessage = (error as Error).message;
472
- if (!errorMessage.includes('cancel') && !errorMessage.includes('User cancelled')) {
473
- emitError(
474
- error instanceof Error ? error : new Error(String(error)),
475
- 'Failed to rotate image.',
476
- );
477
- }
478
- }
479
- } finally {
480
- setIsRotating(false);
481
- }
482
- }, [isRotating, croppedImageData, emitError]);
455
+ // 회전이 필요한 경우 실제로 이미지를 회전
456
+ if (rotationDegrees !== 0) {
457
+ try {
458
+ const rotatedImage = await ImageCropPicker.openCropper({
459
+ path: croppedImageData.path,
460
+ mediaType: 'photo',
461
+ cropping: true,
462
+ freeStyleCropEnabled: true,
463
+ includeBase64: true,
464
+ compressImageQuality: 0.9,
465
+ cropperToolbarTitle: ' ',
466
+ cropperChooseText: '완료',
467
+ cropperCancelText: '취소',
468
+ cropperRotateButtonsHidden: false,
469
+ });
483
470
 
484
- const handleConfirm = useCallback(() => {
485
- if (croppedImageData) {
471
+ onResult({
472
+ path: rotatedImage.path,
473
+ base64: rotatedImage.data ?? undefined,
474
+ });
475
+ } catch (error) {
476
+ console.error('[FullDocScanner] Image rotation error:', error);
477
+ // 에러 발생 시 원본 이미지 전송
478
+ onResult({
479
+ path: croppedImageData.path,
480
+ base64: croppedImageData.base64,
481
+ });
482
+ }
483
+ } else {
486
484
  onResult({
487
485
  path: croppedImageData.path,
488
486
  base64: croppedImageData.base64,
489
487
  });
490
488
  }
491
- }, [croppedImageData, onResult]);
489
+ }, [croppedImageData, rotationDegrees, onResult]);
492
490
 
493
491
  const handleRetake = useCallback(() => {
494
492
  console.log('[FullDocScanner] Retake - clearing cropped image and resetting scanner');
495
493
  setCroppedImageData(null);
494
+ setRotationDegrees(0);
496
495
  setProcessing(false);
497
496
  setRectangleDetected(false);
498
497
  setRectangleHint(false);
@@ -544,29 +543,17 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
544
543
 
545
544
  if (captureReady) {
546
545
  scheduleClear(rectangleCaptureTimeoutRef, () => {
547
- console.log('[FullDocScanner] Rectangle timeout - clearing detection');
548
546
  setRectangleDetected(false);
549
547
  });
550
548
  setRectangleDetected(true);
551
- } else if (!hasRectangle) {
549
+ } else {
550
+ // 그리드가 없거나 품질이 좋지 않으면 즉시 상태 해제
552
551
  if (rectangleCaptureTimeoutRef.current) {
553
552
  clearTimeout(rectangleCaptureTimeoutRef.current);
554
553
  rectangleCaptureTimeoutRef.current = null;
555
554
  }
556
555
  setRectangleDetected(false);
557
- } else if (rectangleDetected) {
558
- scheduleClear(rectangleCaptureTimeoutRef, () => {
559
- console.log('[FullDocScanner] Rectangle timeout - clearing detection');
560
- setRectangleDetected(false);
561
- });
562
556
  }
563
-
564
- console.log('[FullDocScanner] Rectangle detection update', {
565
- lastDetectionType: event.lastDetectionType,
566
- stableCounter,
567
- hasRectangle,
568
- captureReady,
569
- });
570
557
  }, [rectangleDetected]);
571
558
 
572
559
  useEffect(
@@ -586,12 +573,11 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
586
573
  {croppedImageData ? (
587
574
  // check_DP: Show confirmation screen
588
575
  <View style={styles.confirmationContainer}>
589
- {/* 상단 회전 버튼들 */}
590
- <View style={styles.rotateButtonsTop}>
576
+ {/* 회전 버튼들 - 가운데 정렬 */}
577
+ <View style={styles.rotateButtonsCenter}>
591
578
  <TouchableOpacity
592
- style={[styles.rotateButtonTop, isRotating && styles.buttonDisabled]}
579
+ style={styles.rotateButtonTop}
593
580
  onPress={() => handleRotateImage(-90)}
594
- disabled={isRotating}
595
581
  accessibilityLabel="왼쪽으로 90도 회전"
596
582
  accessibilityRole="button"
597
583
  >
@@ -599,9 +585,8 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
599
585
  <Text style={styles.rotateButtonLabel}>좌로 90°</Text>
600
586
  </TouchableOpacity>
601
587
  <TouchableOpacity
602
- style={[styles.rotateButtonTop, isRotating && styles.buttonDisabled]}
588
+ style={styles.rotateButtonTop}
603
589
  onPress={() => handleRotateImage(90)}
604
- disabled={isRotating}
605
590
  accessibilityLabel="오른쪽으로 90도 회전"
606
591
  accessibilityRole="button"
607
592
  >
@@ -611,7 +596,10 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
611
596
  </View>
612
597
  <Image
613
598
  source={{ uri: croppedImageData.path }}
614
- style={styles.previewImage}
599
+ style={[
600
+ styles.previewImage,
601
+ { transform: [{ rotate: `${rotationDegrees}deg` }] }
602
+ ]}
615
603
  resizeMode="contain"
616
604
  />
617
605
  <View style={styles.confirmationButtons}>
@@ -854,6 +842,17 @@ const styles = StyleSheet.create({
854
842
  gap: 12,
855
843
  zIndex: 10,
856
844
  },
845
+ rotateButtonsCenter: {
846
+ position: 'absolute',
847
+ top: 60,
848
+ left: 0,
849
+ right: 0,
850
+ flexDirection: 'row',
851
+ justifyContent: 'center',
852
+ alignItems: 'center',
853
+ gap: 12,
854
+ zIndex: 10,
855
+ },
857
856
  rotateButtonTop: {
858
857
  flexDirection: 'row',
859
858
  alignItems: 'center',