react-native-rectangle-doc-scanner 3.92.0 → 3.94.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, () => ({
@@ -8,6 +8,8 @@ export interface FullDocScannerResult {
8
8
  base64?: string;
9
9
  /** Original captured document info */
10
10
  original?: CapturedDocument;
11
+ /** Rotation degrees (0, 90, 180, 270) */
12
+ rotation?: number;
11
13
  }
12
14
  export interface FullDocScannerStrings {
13
15
  captureHint?: string;
@@ -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,28 @@ 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
- }
363
- }
364
- }
365
- finally {
366
- setIsRotating(false);
367
- }
368
- }, [isRotating, croppedImageData, emitError]);
335
+ // UI에서만 회전 (실제 파일은 confirm 시에 처리)
336
+ setRotationDegrees(prev => {
337
+ const newRotation = (prev + degrees + 360) % 360;
338
+ return newRotation;
339
+ });
340
+ }, [croppedImageData]);
369
341
  const handleConfirm = (0, react_1.useCallback)(() => {
370
- if (croppedImageData) {
371
- onResult({
372
- path: croppedImageData.path,
373
- base64: croppedImageData.base64,
374
- });
375
- }
376
- }, [croppedImageData, onResult]);
342
+ if (!croppedImageData)
343
+ return;
344
+ onResult({
345
+ path: croppedImageData.path,
346
+ base64: croppedImageData.base64,
347
+ rotation: rotationDegrees !== 0 ? rotationDegrees : undefined,
348
+ });
349
+ }, [croppedImageData, rotationDegrees, onResult]);
377
350
  const handleRetake = (0, react_1.useCallback)(() => {
378
351
  console.log('[FullDocScanner] Retake - clearing cropped image and resetting scanner');
379
352
  setCroppedImageData(null);
353
+ setRotationDegrees(0);
380
354
  setProcessing(false);
381
355
  setRectangleDetected(false);
382
356
  setRectangleHint(false);
@@ -447,14 +421,17 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
447
421
  croppedImageData ? (
448
422
  // check_DP: Show confirmation screen
449
423
  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" },
424
+ react_1.default.createElement(react_native_1.View, { style: styles.rotateButtonsCenter },
425
+ 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
426
  react_1.default.createElement(react_native_1.Text, { style: styles.rotateIconText }, "\u21BA"),
453
427
  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" },
428
+ 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
429
  react_1.default.createElement(react_native_1.Text, { style: styles.rotateIconText }, "\u21BB"),
456
430
  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" }),
431
+ react_1.default.createElement(react_native_1.Image, { source: { uri: croppedImageData.path }, style: [
432
+ styles.previewImage,
433
+ { transform: [{ rotate: `${rotationDegrees}deg` }] }
434
+ ], resizeMode: "contain" }),
458
435
  react_1.default.createElement(react_native_1.View, { style: styles.confirmationButtons },
459
436
  react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.confirmButton, styles.retakeButton], onPress: handleRetake, accessibilityLabel: mergedStrings.retake, accessibilityRole: "button" },
460
437
  react_1.default.createElement(react_native_1.Text, { style: styles.confirmButtonText }, mergedStrings.retake)),
@@ -613,6 +590,17 @@ const styles = react_native_1.StyleSheet.create({
613
590
  gap: 12,
614
591
  zIndex: 10,
615
592
  },
593
+ rotateButtonsCenter: {
594
+ position: 'absolute',
595
+ top: 60,
596
+ left: 0,
597
+ right: 0,
598
+ flexDirection: 'row',
599
+ justifyContent: 'center',
600
+ alignItems: 'center',
601
+ gap: 12,
602
+ zIndex: 10,
603
+ },
616
604
  rotateButtonTop: {
617
605
  flexDirection: 'row',
618
606
  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.94.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],
@@ -102,6 +102,8 @@ export interface FullDocScannerResult {
102
102
  base64?: string;
103
103
  /** Original captured document info */
104
104
  original?: CapturedDocument;
105
+ /** Rotation degrees (0, 90, 180, 270) */
106
+ rotation?: number;
105
107
  }
106
108
 
107
109
  export interface FullDocScannerStrings {
@@ -152,7 +154,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
152
154
  const [rectangleDetected, setRectangleDetected] = useState(false);
153
155
  const [rectangleHint, setRectangleHint] = useState(false);
154
156
  const [flashEnabled, setFlashEnabled] = useState(false);
155
- const [isRotating, setIsRotating] = useState(false);
157
+ const [rotationDegrees, setRotationDegrees] = useState(0);
156
158
  const resolvedGridColor = gridColor ?? overlayColor;
157
159
  const docScannerRef = useRef<DocScannerHandle | null>(null);
158
160
  const captureModeRef = useRef<'grid' | 'no-grid' | null>(null);
@@ -440,59 +442,30 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
440
442
  setFlashEnabled(prev => !prev);
441
443
  }, []);
442
444
 
443
- const handleRotateImage = useCallback(async (degrees: -90 | 90) => {
444
- if (isRotating || !croppedImageData) return;
445
+ const handleRotateImage = useCallback((degrees: -90 | 90) => {
446
+ if (!croppedImageData) return;
445
447
 
446
- setIsRotating(true);
447
- try {
448
- console.log('[FullDocScanner] Rotating image by', degrees, 'degrees');
449
-
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
- });
462
-
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]);
448
+ // UI에서만 회전 (실제 파일은 confirm 시에 처리)
449
+ setRotationDegrees(prev => {
450
+ const newRotation = (prev + degrees + 360) % 360;
451
+ return newRotation;
452
+ });
453
+ }, [croppedImageData]);
483
454
 
484
455
  const handleConfirm = useCallback(() => {
485
- if (croppedImageData) {
486
- onResult({
487
- path: croppedImageData.path,
488
- base64: croppedImageData.base64,
489
- });
490
- }
491
- }, [croppedImageData, onResult]);
456
+ if (!croppedImageData) return;
457
+
458
+ onResult({
459
+ path: croppedImageData.path,
460
+ base64: croppedImageData.base64,
461
+ rotation: rotationDegrees !== 0 ? rotationDegrees : undefined,
462
+ });
463
+ }, [croppedImageData, rotationDegrees, onResult]);
492
464
 
493
465
  const handleRetake = useCallback(() => {
494
466
  console.log('[FullDocScanner] Retake - clearing cropped image and resetting scanner');
495
467
  setCroppedImageData(null);
468
+ setRotationDegrees(0);
496
469
  setProcessing(false);
497
470
  setRectangleDetected(false);
498
471
  setRectangleHint(false);
@@ -574,12 +547,11 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
574
547
  {croppedImageData ? (
575
548
  // check_DP: Show confirmation screen
576
549
  <View style={styles.confirmationContainer}>
577
- {/* 상단 회전 버튼들 */}
578
- <View style={styles.rotateButtonsTop}>
550
+ {/* 회전 버튼들 - 가운데 정렬 */}
551
+ <View style={styles.rotateButtonsCenter}>
579
552
  <TouchableOpacity
580
- style={[styles.rotateButtonTop, isRotating && styles.buttonDisabled]}
553
+ style={styles.rotateButtonTop}
581
554
  onPress={() => handleRotateImage(-90)}
582
- disabled={isRotating}
583
555
  accessibilityLabel="왼쪽으로 90도 회전"
584
556
  accessibilityRole="button"
585
557
  >
@@ -587,9 +559,8 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
587
559
  <Text style={styles.rotateButtonLabel}>좌로 90°</Text>
588
560
  </TouchableOpacity>
589
561
  <TouchableOpacity
590
- style={[styles.rotateButtonTop, isRotating && styles.buttonDisabled]}
562
+ style={styles.rotateButtonTop}
591
563
  onPress={() => handleRotateImage(90)}
592
- disabled={isRotating}
593
564
  accessibilityLabel="오른쪽으로 90도 회전"
594
565
  accessibilityRole="button"
595
566
  >
@@ -599,7 +570,10 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
599
570
  </View>
600
571
  <Image
601
572
  source={{ uri: croppedImageData.path }}
602
- style={styles.previewImage}
573
+ style={[
574
+ styles.previewImage,
575
+ { transform: [{ rotate: `${rotationDegrees}deg` }] }
576
+ ]}
603
577
  resizeMode="contain"
604
578
  />
605
579
  <View style={styles.confirmationButtons}>
@@ -842,6 +816,17 @@ const styles = StyleSheet.create({
842
816
  gap: 12,
843
817
  zIndex: 10,
844
818
  },
819
+ rotateButtonsCenter: {
820
+ position: 'absolute',
821
+ top: 60,
822
+ left: 0,
823
+ right: 0,
824
+ flexDirection: 'row',
825
+ justifyContent: 'center',
826
+ alignItems: 'center',
827
+ gap: 12,
828
+ zIndex: 10,
829
+ },
845
830
  rotateButtonTop: {
846
831
  flexDirection: 'row',
847
832
  alignItems: 'center',