react-native-rectangle-doc-scanner 3.92.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);
@@ -447,14 +450,17 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
447
450
  croppedImageData ? (
448
451
  // check_DP: Show confirmation screen
449
452
  react_1.default.createElement(react_native_1.View, { style: styles.confirmationContainer },
450
- react_1.default.createElement(react_native_1.View, { style: styles.rotateButtonsTop },
451
- 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" },
452
455
  react_1.default.createElement(react_native_1.Text, { style: styles.rotateIconText }, "\u21BA"),
453
456
  react_1.default.createElement(react_native_1.Text, { style: styles.rotateButtonLabel }, "\uC88C\uB85C 90\u00B0")),
454
- 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" },
455
458
  react_1.default.createElement(react_native_1.Text, { style: styles.rotateIconText }, "\u21BB"),
456
459
  react_1.default.createElement(react_native_1.Text, { style: styles.rotateButtonLabel }, "\uC6B0\uB85C 90\u00B0"))),
457
- 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" }),
458
464
  react_1.default.createElement(react_native_1.View, { style: styles.confirmationButtons },
459
465
  react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.confirmButton, styles.retakeButton], onPress: handleRetake, accessibilityLabel: mergedStrings.retake, accessibilityRole: "button" },
460
466
  react_1.default.createElement(react_native_1.Text, { style: styles.confirmButtonText }, mergedStrings.retake)),
@@ -613,6 +619,17 @@ const styles = react_native_1.StyleSheet.create({
613
619
  gap: 12,
614
620
  zIndex: 10,
615
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
+ },
616
633
  rotateButtonTop: {
617
634
  flexDirection: 'row',
618
635
  alignItems: 'center',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-rectangle-doc-scanner",
3
- "version": "3.92.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);
@@ -574,12 +573,11 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
574
573
  {croppedImageData ? (
575
574
  // check_DP: Show confirmation screen
576
575
  <View style={styles.confirmationContainer}>
577
- {/* 상단 회전 버튼들 */}
578
- <View style={styles.rotateButtonsTop}>
576
+ {/* 회전 버튼들 - 가운데 정렬 */}
577
+ <View style={styles.rotateButtonsCenter}>
579
578
  <TouchableOpacity
580
- style={[styles.rotateButtonTop, isRotating && styles.buttonDisabled]}
579
+ style={styles.rotateButtonTop}
581
580
  onPress={() => handleRotateImage(-90)}
582
- disabled={isRotating}
583
581
  accessibilityLabel="왼쪽으로 90도 회전"
584
582
  accessibilityRole="button"
585
583
  >
@@ -587,9 +585,8 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
587
585
  <Text style={styles.rotateButtonLabel}>좌로 90°</Text>
588
586
  </TouchableOpacity>
589
587
  <TouchableOpacity
590
- style={[styles.rotateButtonTop, isRotating && styles.buttonDisabled]}
588
+ style={styles.rotateButtonTop}
591
589
  onPress={() => handleRotateImage(90)}
592
- disabled={isRotating}
593
590
  accessibilityLabel="오른쪽으로 90도 회전"
594
591
  accessibilityRole="button"
595
592
  >
@@ -599,7 +596,10 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
599
596
  </View>
600
597
  <Image
601
598
  source={{ uri: croppedImageData.path }}
602
- style={styles.previewImage}
599
+ style={[
600
+ styles.previewImage,
601
+ { transform: [{ rotate: `${rotationDegrees}deg` }] }
602
+ ]}
603
603
  resizeMode="contain"
604
604
  />
605
605
  <View style={styles.confirmationButtons}>
@@ -842,6 +842,17 @@ const styles = StyleSheet.create({
842
842
  gap: 12,
843
843
  zIndex: 10,
844
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
+ },
845
856
  rotateButtonTop: {
846
857
  flexDirection: 'row',
847
858
  alignItems: 'center',