react-native-rectangle-doc-scanner 3.111.0 → 3.114.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.
- package/dist/FullDocScanner.d.ts +7 -1
- package/dist/FullDocScanner.js +101 -90
- package/package.json +1 -1
- package/src/FullDocScanner.tsx +155 -117
package/dist/FullDocScanner.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export interface FullDocScannerResult {
|
|
|
8
8
|
base64?: string;
|
|
9
9
|
/** Original captured document info */
|
|
10
10
|
original?: CapturedDocument;
|
|
11
|
+
/** Rotation angle applied by user (0, 90, 180, 270) */
|
|
12
|
+
rotationDegrees?: number;
|
|
11
13
|
}
|
|
12
14
|
export interface FullDocScannerStrings {
|
|
13
15
|
captureHint?: string;
|
|
@@ -18,9 +20,12 @@ export interface FullDocScannerStrings {
|
|
|
18
20
|
retake?: string;
|
|
19
21
|
confirm?: string;
|
|
20
22
|
cropTitle?: string;
|
|
23
|
+
first?: string;
|
|
24
|
+
second?: string;
|
|
25
|
+
secondBtn?: string;
|
|
21
26
|
}
|
|
22
27
|
export interface FullDocScannerProps {
|
|
23
|
-
onResult: (
|
|
28
|
+
onResult: (results: FullDocScannerResult[]) => void;
|
|
24
29
|
onClose?: () => void;
|
|
25
30
|
detectionConfig?: DetectionConfig;
|
|
26
31
|
overlayColor?: string;
|
|
@@ -33,5 +38,6 @@ export interface FullDocScannerProps {
|
|
|
33
38
|
enableGallery?: boolean;
|
|
34
39
|
cropWidth?: number;
|
|
35
40
|
cropHeight?: number;
|
|
41
|
+
type?: 'business';
|
|
36
42
|
}
|
|
37
43
|
export declare const FullDocScanner: React.FC<FullDocScannerProps>;
|
package/dist/FullDocScanner.js
CHANGED
|
@@ -52,9 +52,9 @@ catch (error) {
|
|
|
52
52
|
}
|
|
53
53
|
let expoManipulatorUnavailable = false;
|
|
54
54
|
const isExpoImageManipulatorAvailable = () => !!ImageManipulator?.manipulateAsync && !expoManipulatorUnavailable;
|
|
55
|
-
|
|
55
|
+
// 회전은 항상 지원됨 (회전 각도를 반환하고 tdb 앱에서 처리)
|
|
56
|
+
const isImageRotationSupported = () => true;
|
|
56
57
|
const stripFileUri = (value) => value.replace(/^file:\/\//, '');
|
|
57
|
-
const ensureFileUri = (value) => (value.startsWith('file://') ? value : `file://${value}`);
|
|
58
58
|
const CROPPER_TIMEOUT_MS = 8000;
|
|
59
59
|
const CROPPER_TIMEOUT_CODE = 'cropper_timeout';
|
|
60
60
|
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -115,7 +115,7 @@ const normalizeCapturedDocument = (document) => {
|
|
|
115
115
|
croppedPath: document.croppedPath ? stripFileUri(document.croppedPath) : null,
|
|
116
116
|
};
|
|
117
117
|
};
|
|
118
|
-
const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3170f3', gridColor, gridLineWidth, showGrid, strings, minStableFrames, onError, enableGallery = true, cropWidth = 1200, cropHeight = 1600, }) => {
|
|
118
|
+
const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3170f3', gridColor, gridLineWidth, showGrid, strings, minStableFrames, onError, enableGallery = true, cropWidth = 1200, cropHeight = 1600, type, }) => {
|
|
119
119
|
const [processing, setProcessing] = (0, react_1.useState)(false);
|
|
120
120
|
const [croppedImageData, setCroppedImageData] = (0, react_1.useState)(null);
|
|
121
121
|
const [isGalleryOpen, setIsGalleryOpen] = (0, react_1.useState)(false);
|
|
@@ -123,12 +123,16 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
123
123
|
const [rectangleHint, setRectangleHint] = (0, react_1.useState)(false);
|
|
124
124
|
const [flashEnabled, setFlashEnabled] = (0, react_1.useState)(false);
|
|
125
125
|
const [rotationDegrees, setRotationDegrees] = (0, react_1.useState)(0);
|
|
126
|
+
const [capturedPhotos, setCapturedPhotos] = (0, react_1.useState)([]);
|
|
127
|
+
const [currentPhotoIndex, setCurrentPhotoIndex] = (0, react_1.useState)(0);
|
|
126
128
|
const resolvedGridColor = gridColor ?? overlayColor;
|
|
127
129
|
const docScannerRef = (0, react_1.useRef)(null);
|
|
128
130
|
const captureModeRef = (0, react_1.useRef)(null);
|
|
129
131
|
const captureInProgressRef = (0, react_1.useRef)(false);
|
|
130
132
|
const rectangleCaptureTimeoutRef = (0, react_1.useRef)(null);
|
|
131
133
|
const rectangleHintTimeoutRef = (0, react_1.useRef)(null);
|
|
134
|
+
const isBusinessMode = type === 'business';
|
|
135
|
+
const maxPhotos = isBusinessMode ? 2 : 1;
|
|
132
136
|
const mergedStrings = (0, react_1.useMemo)(() => ({
|
|
133
137
|
captureHint: strings?.captureHint,
|
|
134
138
|
manualHint: strings?.manualHint,
|
|
@@ -138,6 +142,9 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
138
142
|
retake: strings?.retake ?? 'Retake',
|
|
139
143
|
confirm: strings?.confirm ?? 'Confirm',
|
|
140
144
|
cropTitle: strings?.cropTitle ?? 'Crop Document',
|
|
145
|
+
first: strings?.first ?? 'Front',
|
|
146
|
+
second: strings?.second ?? 'Back',
|
|
147
|
+
secondBtn: strings?.secondBtn ?? 'Capture Back Side?',
|
|
141
148
|
}), [strings]);
|
|
142
149
|
const emitError = (0, react_1.useCallback)((error, fallbackMessage) => {
|
|
143
150
|
console.error('[FullDocScanner] error', error);
|
|
@@ -374,95 +381,56 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
374
381
|
if (!croppedImageData) {
|
|
375
382
|
return;
|
|
376
383
|
}
|
|
377
|
-
//
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
+
// 회전 각도 정규화 (0, 90, 180, 270)
|
|
385
|
+
const rotationNormalized = ((rotationDegrees % 360) + 360) % 360;
|
|
386
|
+
console.log('[FullDocScanner] Confirm - rotation degrees:', rotationDegrees, 'normalized:', rotationNormalized);
|
|
387
|
+
// 현재 사진을 capturedPhotos에 추가
|
|
388
|
+
const currentPhoto = {
|
|
389
|
+
path: croppedImageData.path,
|
|
390
|
+
base64: croppedImageData.base64,
|
|
391
|
+
rotationDegrees: rotationNormalized,
|
|
392
|
+
};
|
|
393
|
+
const updatedPhotos = [...capturedPhotos, currentPhoto];
|
|
394
|
+
console.log('[FullDocScanner] Photos captured:', updatedPhotos.length, 'of', maxPhotos);
|
|
395
|
+
// Business 모드이고 아직 첫 번째 사진만 찍은 경우
|
|
396
|
+
if (isBusinessMode && updatedPhotos.length === 1) {
|
|
397
|
+
// 두 번째 사진 촬영 여부를 물어봄 (UI에서 버튼으로 표시)
|
|
398
|
+
setCapturedPhotos(updatedPhotos);
|
|
399
|
+
setCurrentPhotoIndex(1);
|
|
400
|
+
// 확인 화면을 유지하고 "뒷면 촬영" 버튼을 표시
|
|
384
401
|
return;
|
|
385
402
|
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
console.log('[FullDocScanner] Using expo-image-manipulator for rotation');
|
|
404
|
-
const inputUri = ensureFileUri(croppedImageData.path);
|
|
405
|
-
const result = await ImageManipulator.manipulateAsync(inputUri, [{ rotate: rotationNormalized }], {
|
|
406
|
-
compress: 0.9,
|
|
407
|
-
format: ImageManipulator.SaveFormat.JPEG,
|
|
408
|
-
base64: true,
|
|
409
|
-
});
|
|
410
|
-
console.log('[FullDocScanner] Rotation complete via expo-image-manipulator:', {
|
|
411
|
-
path: result.uri,
|
|
412
|
-
hasBase64: !!result.base64,
|
|
413
|
-
});
|
|
414
|
-
onResult({
|
|
415
|
-
path: stripFileUri(result.uri),
|
|
416
|
-
base64: result.base64,
|
|
417
|
-
});
|
|
418
|
-
setProcessing(false);
|
|
419
|
-
return;
|
|
420
|
-
}
|
|
421
|
-
catch (manipulatorError) {
|
|
422
|
-
const code = manipulatorError && typeof manipulatorError === 'object' && 'code' in manipulatorError
|
|
423
|
-
? String(manipulatorError.code)
|
|
424
|
-
: undefined;
|
|
425
|
-
console.error('[FullDocScanner] expo-image-manipulator error:', manipulatorError);
|
|
426
|
-
if (code === 'ERR_UNAVAILABLE') {
|
|
427
|
-
expoManipulatorUnavailable = true;
|
|
428
|
-
console.warn('[FullDocScanner] expo-image-manipulator unavailable at runtime. Trying react-native-image-rotate.');
|
|
429
|
-
}
|
|
430
|
-
else {
|
|
431
|
-
throw manipulatorError;
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
// expo-image-manipulator를 사용할 수 없는 경우 에러 처리
|
|
436
|
-
console.error('[FullDocScanner] Rotation requested but expo-image-manipulator is not available.');
|
|
437
|
-
setProcessing(false);
|
|
438
|
-
react_native_1.Alert.alert('회전 불가', 'expo-image-manipulator가 설치되지 않아 이미지 회전을 수행할 수 없습니다.\n\n패키지를 설치해주세요:\nnpm install expo-image-manipulator', [
|
|
439
|
-
{
|
|
440
|
-
text: '원본 사용',
|
|
441
|
-
onPress: () => {
|
|
442
|
-
onResult({
|
|
443
|
-
path: croppedImageData.path,
|
|
444
|
-
base64: croppedImageData.base64,
|
|
445
|
-
});
|
|
446
|
-
},
|
|
447
|
-
},
|
|
448
|
-
{
|
|
449
|
-
text: '취소',
|
|
450
|
-
style: 'cancel',
|
|
451
|
-
},
|
|
452
|
-
]);
|
|
403
|
+
// 모든 사진 촬영 완료 - 결과 반환
|
|
404
|
+
console.log('[FullDocScanner] All photos captured, returning results');
|
|
405
|
+
onResult(updatedPhotos);
|
|
406
|
+
}, [croppedImageData, rotationDegrees, capturedPhotos, isBusinessMode, maxPhotos, onResult]);
|
|
407
|
+
const handleCaptureSecondPhoto = (0, react_1.useCallback)(() => {
|
|
408
|
+
console.log('[FullDocScanner] Capturing second photo');
|
|
409
|
+
// 확인 화면을 닫고 카메라로 돌아감
|
|
410
|
+
setCroppedImageData(null);
|
|
411
|
+
setRotationDegrees(0);
|
|
412
|
+
setProcessing(false);
|
|
413
|
+
setRectangleDetected(false);
|
|
414
|
+
setRectangleHint(false);
|
|
415
|
+
captureModeRef.current = null;
|
|
416
|
+
captureInProgressRef.current = false;
|
|
417
|
+
if (rectangleCaptureTimeoutRef.current) {
|
|
418
|
+
clearTimeout(rectangleCaptureTimeoutRef.current);
|
|
419
|
+
rectangleCaptureTimeoutRef.current = null;
|
|
453
420
|
}
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
const errorMessage = error && typeof error === 'object' && 'message' in error ? error.message : String(error);
|
|
458
|
-
react_native_1.Alert.alert('회전 실패', `이미지 회전 중 오류가 발생했습니다.\n원본 이미지를 사용합니다.\n\n오류: ${errorMessage}`);
|
|
459
|
-
// 에러 발생 시 원본 이미지 반환
|
|
460
|
-
onResult({
|
|
461
|
-
path: croppedImageData.path,
|
|
462
|
-
base64: croppedImageData.base64,
|
|
463
|
-
});
|
|
421
|
+
if (rectangleHintTimeoutRef.current) {
|
|
422
|
+
clearTimeout(rectangleHintTimeoutRef.current);
|
|
423
|
+
rectangleHintTimeoutRef.current = null;
|
|
464
424
|
}
|
|
465
|
-
|
|
425
|
+
if (docScannerRef.current?.reset) {
|
|
426
|
+
docScannerRef.current.reset();
|
|
427
|
+
}
|
|
428
|
+
}, []);
|
|
429
|
+
const handleSkipSecondPhoto = (0, react_1.useCallback)(() => {
|
|
430
|
+
console.log('[FullDocScanner] Skipping second photo');
|
|
431
|
+
// 첫 번째 사진만 반환
|
|
432
|
+
onResult(capturedPhotos);
|
|
433
|
+
}, [capturedPhotos, onResult]);
|
|
466
434
|
const handleRetake = (0, react_1.useCallback)(() => {
|
|
467
435
|
console.log('[FullDocScanner] Retake - clearing cropped image and resetting scanner');
|
|
468
436
|
setCroppedImageData(null);
|
|
@@ -537,6 +505,8 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
537
505
|
croppedImageData ? (
|
|
538
506
|
// check_DP: Show confirmation screen
|
|
539
507
|
react_1.default.createElement(react_native_1.View, { style: styles.confirmationContainer },
|
|
508
|
+
isBusinessMode && (react_1.default.createElement(react_native_1.View, { style: styles.photoHeader },
|
|
509
|
+
react_1.default.createElement(react_native_1.Text, { style: styles.photoHeaderText }, currentPhotoIndex === 0 ? mergedStrings.first : mergedStrings.second))),
|
|
540
510
|
isImageRotationSupported() ? (react_1.default.createElement(react_native_1.View, { style: styles.rotateButtonsCenter },
|
|
541
511
|
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" },
|
|
542
512
|
react_1.default.createElement(react_native_1.Text, { style: styles.rotateIconText }, "\u21BA"),
|
|
@@ -551,8 +521,12 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
551
521
|
react_1.default.createElement(react_native_1.View, { style: styles.confirmationButtons },
|
|
552
522
|
react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.confirmButton, styles.retakeButton], onPress: handleRetake, accessibilityLabel: mergedStrings.retake, accessibilityRole: "button" },
|
|
553
523
|
react_1.default.createElement(react_native_1.Text, { style: styles.confirmButtonText }, mergedStrings.retake)),
|
|
554
|
-
|
|
555
|
-
react_1.default.createElement(react_native_1.
|
|
524
|
+
isBusinessMode && capturedPhotos.length === 1 && currentPhotoIndex === 1 ? (react_1.default.createElement(react_1.default.Fragment, null,
|
|
525
|
+
react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.confirmButton, styles.confirmButtonPrimary], onPress: handleCaptureSecondPhoto, accessibilityLabel: mergedStrings.secondBtn, accessibilityRole: "button" },
|
|
526
|
+
react_1.default.createElement(react_native_1.Text, { style: styles.confirmButtonText }, mergedStrings.secondBtn)),
|
|
527
|
+
react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.confirmButton, styles.skipButton], onPress: handleSkipSecondPhoto, accessibilityLabel: mergedStrings.confirm, accessibilityRole: "button" },
|
|
528
|
+
react_1.default.createElement(react_native_1.Text, { style: styles.confirmButtonText }, mergedStrings.confirm)))) : (react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.confirmButton, styles.confirmButtonPrimary], onPress: handleConfirm, accessibilityLabel: mergedStrings.confirm, accessibilityRole: "button" },
|
|
529
|
+
react_1.default.createElement(react_native_1.Text, { style: styles.confirmButtonText }, mergedStrings.confirm)))))) : (react_1.default.createElement(react_native_1.View, { style: styles.flex },
|
|
556
530
|
react_1.default.createElement(DocScanner_1.DocScanner, { ref: docScannerRef, autoCapture: false, overlayColor: overlayColor, showGrid: showGrid, gridColor: resolvedGridColor, gridLineWidth: gridLineWidth, minStableFrames: minStableFrames ?? 6, detectionConfig: detectionConfig, onCapture: handleCapture, onRectangleDetect: handleRectangleDetect, showManualCaptureButton: false, enableTorch: flashEnabled },
|
|
557
531
|
react_1.default.createElement(react_native_1.View, { style: styles.overlayTop, pointerEvents: "box-none" },
|
|
558
532
|
react_1.default.createElement(react_native_1.TouchableOpacity, { style: [
|
|
@@ -562,6 +536,8 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
562
536
|
], onPress: handleFlashToggle, disabled: processing, accessibilityLabel: "Toggle flash", accessibilityRole: "button" },
|
|
563
537
|
react_1.default.createElement(react_native_1.View, { style: styles.iconContainer },
|
|
564
538
|
react_1.default.createElement(react_native_1.Text, { style: styles.iconText }, "\u26A1\uFE0F"))),
|
|
539
|
+
isBusinessMode && (react_1.default.createElement(react_native_1.View, { style: styles.cameraHeaderContainer },
|
|
540
|
+
react_1.default.createElement(react_native_1.Text, { style: styles.cameraHeaderText }, currentPhotoIndex === 0 ? mergedStrings.first : mergedStrings.second))),
|
|
565
541
|
react_1.default.createElement(react_native_1.TouchableOpacity, { style: styles.iconButton, onPress: handleClose, accessibilityLabel: mergedStrings.cancel, accessibilityRole: "button" },
|
|
566
542
|
react_1.default.createElement(react_native_1.View, { style: styles.iconContainer },
|
|
567
543
|
react_1.default.createElement(react_native_1.Text, { style: styles.closeIconText }, "\u00D7")))),
|
|
@@ -765,9 +741,44 @@ const styles = react_native_1.StyleSheet.create({
|
|
|
765
741
|
confirmButtonPrimary: {
|
|
766
742
|
backgroundColor: '#3170f3',
|
|
767
743
|
},
|
|
744
|
+
skipButton: {
|
|
745
|
+
backgroundColor: 'rgba(100,100,100,0.8)',
|
|
746
|
+
},
|
|
768
747
|
confirmButtonText: {
|
|
769
748
|
color: '#fff',
|
|
770
749
|
fontSize: 18,
|
|
771
750
|
fontWeight: '600',
|
|
772
751
|
},
|
|
752
|
+
photoHeader: {
|
|
753
|
+
position: 'absolute',
|
|
754
|
+
top: 20,
|
|
755
|
+
left: 0,
|
|
756
|
+
right: 0,
|
|
757
|
+
alignItems: 'center',
|
|
758
|
+
zIndex: 5,
|
|
759
|
+
},
|
|
760
|
+
photoHeaderText: {
|
|
761
|
+
color: '#fff',
|
|
762
|
+
fontSize: 20,
|
|
763
|
+
fontWeight: 'bold',
|
|
764
|
+
backgroundColor: 'rgba(0,0,0,0.6)',
|
|
765
|
+
paddingHorizontal: 24,
|
|
766
|
+
paddingVertical: 8,
|
|
767
|
+
borderRadius: 20,
|
|
768
|
+
},
|
|
769
|
+
cameraHeaderContainer: {
|
|
770
|
+
position: 'absolute',
|
|
771
|
+
left: 0,
|
|
772
|
+
right: 0,
|
|
773
|
+
alignItems: 'center',
|
|
774
|
+
},
|
|
775
|
+
cameraHeaderText: {
|
|
776
|
+
color: '#fff',
|
|
777
|
+
fontSize: 18,
|
|
778
|
+
fontWeight: 'bold',
|
|
779
|
+
backgroundColor: 'rgba(0,0,0,0.7)',
|
|
780
|
+
paddingHorizontal: 20,
|
|
781
|
+
paddingVertical: 6,
|
|
782
|
+
borderRadius: 16,
|
|
783
|
+
},
|
|
773
784
|
});
|
package/package.json
CHANGED
package/src/FullDocScanner.tsx
CHANGED
|
@@ -37,10 +37,10 @@ try {
|
|
|
37
37
|
let expoManipulatorUnavailable = false;
|
|
38
38
|
const isExpoImageManipulatorAvailable = () =>
|
|
39
39
|
!!ImageManipulator?.manipulateAsync && !expoManipulatorUnavailable;
|
|
40
|
-
|
|
40
|
+
// 회전은 항상 지원됨 (회전 각도를 반환하고 tdb 앱에서 처리)
|
|
41
|
+
const isImageRotationSupported = () => true;
|
|
41
42
|
|
|
42
43
|
const stripFileUri = (value: string) => value.replace(/^file:\/\//, '');
|
|
43
|
-
const ensureFileUri = (value: string) => (value.startsWith('file://') ? value : `file://${value}`);
|
|
44
44
|
|
|
45
45
|
const CROPPER_TIMEOUT_MS = 8000;
|
|
46
46
|
const CROPPER_TIMEOUT_CODE = 'cropper_timeout';
|
|
@@ -122,6 +122,8 @@ export interface FullDocScannerResult {
|
|
|
122
122
|
base64?: string;
|
|
123
123
|
/** Original captured document info */
|
|
124
124
|
original?: CapturedDocument;
|
|
125
|
+
/** Rotation angle applied by user (0, 90, 180, 270) */
|
|
126
|
+
rotationDegrees?: number;
|
|
125
127
|
}
|
|
126
128
|
|
|
127
129
|
export interface FullDocScannerStrings {
|
|
@@ -133,10 +135,13 @@ export interface FullDocScannerStrings {
|
|
|
133
135
|
retake?: string;
|
|
134
136
|
confirm?: string;
|
|
135
137
|
cropTitle?: string;
|
|
138
|
+
first?: string;
|
|
139
|
+
second?: string;
|
|
140
|
+
secondBtn?: string;
|
|
136
141
|
}
|
|
137
142
|
|
|
138
143
|
export interface FullDocScannerProps {
|
|
139
|
-
onResult: (
|
|
144
|
+
onResult: (results: FullDocScannerResult[]) => void;
|
|
140
145
|
onClose?: () => void;
|
|
141
146
|
detectionConfig?: DetectionConfig;
|
|
142
147
|
overlayColor?: string;
|
|
@@ -149,6 +154,7 @@ export interface FullDocScannerProps {
|
|
|
149
154
|
enableGallery?: boolean;
|
|
150
155
|
cropWidth?: number;
|
|
151
156
|
cropHeight?: number;
|
|
157
|
+
type?: 'business';
|
|
152
158
|
}
|
|
153
159
|
|
|
154
160
|
export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
@@ -165,6 +171,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
165
171
|
enableGallery = true,
|
|
166
172
|
cropWidth = 1200,
|
|
167
173
|
cropHeight = 1600,
|
|
174
|
+
type,
|
|
168
175
|
}) => {
|
|
169
176
|
const [processing, setProcessing] = useState(false);
|
|
170
177
|
const [croppedImageData, setCroppedImageData] = useState<{path: string; base64?: string} | null>(null);
|
|
@@ -173,6 +180,8 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
173
180
|
const [rectangleHint, setRectangleHint] = useState(false);
|
|
174
181
|
const [flashEnabled, setFlashEnabled] = useState(false);
|
|
175
182
|
const [rotationDegrees, setRotationDegrees] = useState(0);
|
|
183
|
+
const [capturedPhotos, setCapturedPhotos] = useState<FullDocScannerResult[]>([]);
|
|
184
|
+
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
|
|
176
185
|
const resolvedGridColor = gridColor ?? overlayColor;
|
|
177
186
|
const docScannerRef = useRef<DocScannerHandle | null>(null);
|
|
178
187
|
const captureModeRef = useRef<'grid' | 'no-grid' | null>(null);
|
|
@@ -180,6 +189,9 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
180
189
|
const rectangleCaptureTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
181
190
|
const rectangleHintTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
182
191
|
|
|
192
|
+
const isBusinessMode = type === 'business';
|
|
193
|
+
const maxPhotos = isBusinessMode ? 2 : 1;
|
|
194
|
+
|
|
183
195
|
const mergedStrings = useMemo(
|
|
184
196
|
() => ({
|
|
185
197
|
captureHint: strings?.captureHint,
|
|
@@ -190,6 +202,9 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
190
202
|
retake: strings?.retake ?? 'Retake',
|
|
191
203
|
confirm: strings?.confirm ?? 'Confirm',
|
|
192
204
|
cropTitle: strings?.cropTitle ?? 'Crop Document',
|
|
205
|
+
first: strings?.first ?? 'Front',
|
|
206
|
+
second: strings?.second ?? 'Back',
|
|
207
|
+
secondBtn: strings?.secondBtn ?? 'Capture Back Side?',
|
|
193
208
|
}),
|
|
194
209
|
[strings],
|
|
195
210
|
);
|
|
@@ -500,118 +515,62 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
500
515
|
return;
|
|
501
516
|
}
|
|
502
517
|
|
|
503
|
-
//
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
onResult({
|
|
507
|
-
path: croppedImageData.path,
|
|
508
|
-
base64: croppedImageData.base64,
|
|
509
|
-
});
|
|
510
|
-
return;
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
try {
|
|
514
|
-
setProcessing(true);
|
|
518
|
+
// 회전 각도 정규화 (0, 90, 180, 270)
|
|
519
|
+
const rotationNormalized = ((rotationDegrees % 360) + 360) % 360;
|
|
520
|
+
console.log('[FullDocScanner] Confirm - rotation degrees:', rotationDegrees, 'normalized:', rotationNormalized);
|
|
515
521
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
onResult({
|
|
523
|
-
path: croppedImageData.path,
|
|
524
|
-
base64: croppedImageData.base64,
|
|
525
|
-
});
|
|
526
|
-
setProcessing(false);
|
|
527
|
-
return;
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
// expo-image-manipulator 시도
|
|
531
|
-
if (isExpoImageManipulatorAvailable() && ImageManipulator?.manipulateAsync) {
|
|
532
|
-
try {
|
|
533
|
-
console.log('[FullDocScanner] Using expo-image-manipulator for rotation');
|
|
534
|
-
const inputUri = ensureFileUri(croppedImageData.path);
|
|
535
|
-
|
|
536
|
-
const result = await ImageManipulator.manipulateAsync(
|
|
537
|
-
inputUri,
|
|
538
|
-
[{ rotate: rotationNormalized }],
|
|
539
|
-
{
|
|
540
|
-
compress: 0.9,
|
|
541
|
-
format: ImageManipulator.SaveFormat.JPEG,
|
|
542
|
-
base64: true,
|
|
543
|
-
},
|
|
544
|
-
);
|
|
545
|
-
|
|
546
|
-
console.log('[FullDocScanner] Rotation complete via expo-image-manipulator:', {
|
|
547
|
-
path: result.uri,
|
|
548
|
-
hasBase64: !!result.base64,
|
|
549
|
-
});
|
|
522
|
+
// 현재 사진을 capturedPhotos에 추가
|
|
523
|
+
const currentPhoto: FullDocScannerResult = {
|
|
524
|
+
path: croppedImageData.path,
|
|
525
|
+
base64: croppedImageData.base64,
|
|
526
|
+
rotationDegrees: rotationNormalized,
|
|
527
|
+
};
|
|
550
528
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
base64: result.base64,
|
|
554
|
-
});
|
|
555
|
-
setProcessing(false);
|
|
556
|
-
return;
|
|
557
|
-
} catch (manipulatorError) {
|
|
558
|
-
const code =
|
|
559
|
-
manipulatorError && typeof manipulatorError === 'object' && 'code' in manipulatorError
|
|
560
|
-
? String((manipulatorError as any).code)
|
|
561
|
-
: undefined;
|
|
562
|
-
|
|
563
|
-
console.error('[FullDocScanner] expo-image-manipulator error:', manipulatorError);
|
|
564
|
-
|
|
565
|
-
if (code === 'ERR_UNAVAILABLE') {
|
|
566
|
-
expoManipulatorUnavailable = true;
|
|
567
|
-
console.warn(
|
|
568
|
-
'[FullDocScanner] expo-image-manipulator unavailable at runtime. Trying react-native-image-rotate.',
|
|
569
|
-
);
|
|
570
|
-
} else {
|
|
571
|
-
throw manipulatorError;
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
}
|
|
529
|
+
const updatedPhotos = [...capturedPhotos, currentPhoto];
|
|
530
|
+
console.log('[FullDocScanner] Photos captured:', updatedPhotos.length, 'of', maxPhotos);
|
|
575
531
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
);
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
[
|
|
585
|
-
{
|
|
586
|
-
text: '원본 사용',
|
|
587
|
-
onPress: () => {
|
|
588
|
-
onResult({
|
|
589
|
-
path: croppedImageData.path,
|
|
590
|
-
base64: croppedImageData.base64,
|
|
591
|
-
});
|
|
592
|
-
},
|
|
593
|
-
},
|
|
594
|
-
{
|
|
595
|
-
text: '취소',
|
|
596
|
-
style: 'cancel',
|
|
597
|
-
},
|
|
598
|
-
],
|
|
599
|
-
);
|
|
600
|
-
} catch (error) {
|
|
601
|
-
console.error('[FullDocScanner] Image rotation error on confirm:', error);
|
|
602
|
-
setProcessing(false);
|
|
532
|
+
// Business 모드이고 아직 첫 번째 사진만 찍은 경우
|
|
533
|
+
if (isBusinessMode && updatedPhotos.length === 1) {
|
|
534
|
+
// 두 번째 사진 촬영 여부를 물어봄 (UI에서 버튼으로 표시)
|
|
535
|
+
setCapturedPhotos(updatedPhotos);
|
|
536
|
+
setCurrentPhotoIndex(1);
|
|
537
|
+
// 확인 화면을 유지하고 "뒷면 촬영" 버튼을 표시
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
603
540
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
541
|
+
// 모든 사진 촬영 완료 - 결과 반환
|
|
542
|
+
console.log('[FullDocScanner] All photos captured, returning results');
|
|
543
|
+
onResult(updatedPhotos);
|
|
544
|
+
}, [croppedImageData, rotationDegrees, capturedPhotos, isBusinessMode, maxPhotos, onResult]);
|
|
607
545
|
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
546
|
+
const handleCaptureSecondPhoto = useCallback(() => {
|
|
547
|
+
console.log('[FullDocScanner] Capturing second photo');
|
|
548
|
+
// 확인 화면을 닫고 카메라로 돌아감
|
|
549
|
+
setCroppedImageData(null);
|
|
550
|
+
setRotationDegrees(0);
|
|
551
|
+
setProcessing(false);
|
|
552
|
+
setRectangleDetected(false);
|
|
553
|
+
setRectangleHint(false);
|
|
554
|
+
captureModeRef.current = null;
|
|
555
|
+
captureInProgressRef.current = false;
|
|
556
|
+
if (rectangleCaptureTimeoutRef.current) {
|
|
557
|
+
clearTimeout(rectangleCaptureTimeoutRef.current);
|
|
558
|
+
rectangleCaptureTimeoutRef.current = null;
|
|
559
|
+
}
|
|
560
|
+
if (rectangleHintTimeoutRef.current) {
|
|
561
|
+
clearTimeout(rectangleHintTimeoutRef.current);
|
|
562
|
+
rectangleHintTimeoutRef.current = null;
|
|
613
563
|
}
|
|
614
|
-
|
|
564
|
+
if (docScannerRef.current?.reset) {
|
|
565
|
+
docScannerRef.current.reset();
|
|
566
|
+
}
|
|
567
|
+
}, []);
|
|
568
|
+
|
|
569
|
+
const handleSkipSecondPhoto = useCallback(() => {
|
|
570
|
+
console.log('[FullDocScanner] Skipping second photo');
|
|
571
|
+
// 첫 번째 사진만 반환
|
|
572
|
+
onResult(capturedPhotos);
|
|
573
|
+
}, [capturedPhotos, onResult]);
|
|
615
574
|
|
|
616
575
|
const handleRetake = useCallback(() => {
|
|
617
576
|
console.log('[FullDocScanner] Retake - clearing cropped image and resetting scanner');
|
|
@@ -698,6 +657,15 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
698
657
|
{croppedImageData ? (
|
|
699
658
|
// check_DP: Show confirmation screen
|
|
700
659
|
<View style={styles.confirmationContainer}>
|
|
660
|
+
{/* 헤더 - 앞면/뒷면 표시 */}
|
|
661
|
+
{isBusinessMode && (
|
|
662
|
+
<View style={styles.photoHeader}>
|
|
663
|
+
<Text style={styles.photoHeaderText}>
|
|
664
|
+
{currentPhotoIndex === 0 ? mergedStrings.first : mergedStrings.second}
|
|
665
|
+
</Text>
|
|
666
|
+
</View>
|
|
667
|
+
)}
|
|
668
|
+
|
|
701
669
|
{/* 회전 버튼들 - 가운데 정렬 */}
|
|
702
670
|
{isImageRotationSupported() ? (
|
|
703
671
|
<View style={styles.rotateButtonsCenter}>
|
|
@@ -738,14 +706,37 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
738
706
|
>
|
|
739
707
|
<Text style={styles.confirmButtonText}>{mergedStrings.retake}</Text>
|
|
740
708
|
</TouchableOpacity>
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
709
|
+
|
|
710
|
+
{/* Business 모드이고 첫 번째 사진을 찍은 후: 뒷면 촬영 버튼 또는 확인 버튼 */}
|
|
711
|
+
{isBusinessMode && capturedPhotos.length === 1 && currentPhotoIndex === 1 ? (
|
|
712
|
+
<>
|
|
713
|
+
<TouchableOpacity
|
|
714
|
+
style={[styles.confirmButton, styles.confirmButtonPrimary]}
|
|
715
|
+
onPress={handleCaptureSecondPhoto}
|
|
716
|
+
accessibilityLabel={mergedStrings.secondBtn}
|
|
717
|
+
accessibilityRole="button"
|
|
718
|
+
>
|
|
719
|
+
<Text style={styles.confirmButtonText}>{mergedStrings.secondBtn}</Text>
|
|
720
|
+
</TouchableOpacity>
|
|
721
|
+
<TouchableOpacity
|
|
722
|
+
style={[styles.confirmButton, styles.skipButton]}
|
|
723
|
+
onPress={handleSkipSecondPhoto}
|
|
724
|
+
accessibilityLabel={mergedStrings.confirm}
|
|
725
|
+
accessibilityRole="button"
|
|
726
|
+
>
|
|
727
|
+
<Text style={styles.confirmButtonText}>{mergedStrings.confirm}</Text>
|
|
728
|
+
</TouchableOpacity>
|
|
729
|
+
</>
|
|
730
|
+
) : (
|
|
731
|
+
<TouchableOpacity
|
|
732
|
+
style={[styles.confirmButton, styles.confirmButtonPrimary]}
|
|
733
|
+
onPress={handleConfirm}
|
|
734
|
+
accessibilityLabel={mergedStrings.confirm}
|
|
735
|
+
accessibilityRole="button"
|
|
736
|
+
>
|
|
737
|
+
<Text style={styles.confirmButtonText}>{mergedStrings.confirm}</Text>
|
|
738
|
+
</TouchableOpacity>
|
|
739
|
+
)}
|
|
749
740
|
</View>
|
|
750
741
|
</View>
|
|
751
742
|
) : (
|
|
@@ -765,6 +756,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
765
756
|
enableTorch={flashEnabled}
|
|
766
757
|
>
|
|
767
758
|
<View style={styles.overlayTop} pointerEvents="box-none">
|
|
759
|
+
{/* 좌측: 플래시 버튼 */}
|
|
768
760
|
<TouchableOpacity
|
|
769
761
|
style={[
|
|
770
762
|
styles.iconButton,
|
|
@@ -780,6 +772,17 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
780
772
|
<Text style={styles.iconText}>⚡️</Text>
|
|
781
773
|
</View>
|
|
782
774
|
</TouchableOpacity>
|
|
775
|
+
|
|
776
|
+
{/* 중앙: 앞면/뒷면 헤더 */}
|
|
777
|
+
{isBusinessMode && (
|
|
778
|
+
<View style={styles.cameraHeaderContainer}>
|
|
779
|
+
<Text style={styles.cameraHeaderText}>
|
|
780
|
+
{currentPhotoIndex === 0 ? mergedStrings.first : mergedStrings.second}
|
|
781
|
+
</Text>
|
|
782
|
+
</View>
|
|
783
|
+
)}
|
|
784
|
+
|
|
785
|
+
{/* 우측: 닫기 버튼 */}
|
|
783
786
|
<TouchableOpacity
|
|
784
787
|
style={styles.iconButton}
|
|
785
788
|
onPress={handleClose}
|
|
@@ -1028,9 +1031,44 @@ const styles = StyleSheet.create({
|
|
|
1028
1031
|
confirmButtonPrimary: {
|
|
1029
1032
|
backgroundColor: '#3170f3',
|
|
1030
1033
|
},
|
|
1034
|
+
skipButton: {
|
|
1035
|
+
backgroundColor: 'rgba(100,100,100,0.8)',
|
|
1036
|
+
},
|
|
1031
1037
|
confirmButtonText: {
|
|
1032
1038
|
color: '#fff',
|
|
1033
1039
|
fontSize: 18,
|
|
1034
1040
|
fontWeight: '600',
|
|
1035
1041
|
},
|
|
1042
|
+
photoHeader: {
|
|
1043
|
+
position: 'absolute',
|
|
1044
|
+
top: 20,
|
|
1045
|
+
left: 0,
|
|
1046
|
+
right: 0,
|
|
1047
|
+
alignItems: 'center',
|
|
1048
|
+
zIndex: 5,
|
|
1049
|
+
},
|
|
1050
|
+
photoHeaderText: {
|
|
1051
|
+
color: '#fff',
|
|
1052
|
+
fontSize: 20,
|
|
1053
|
+
fontWeight: 'bold',
|
|
1054
|
+
backgroundColor: 'rgba(0,0,0,0.6)',
|
|
1055
|
+
paddingHorizontal: 24,
|
|
1056
|
+
paddingVertical: 8,
|
|
1057
|
+
borderRadius: 20,
|
|
1058
|
+
},
|
|
1059
|
+
cameraHeaderContainer: {
|
|
1060
|
+
position: 'absolute',
|
|
1061
|
+
left: 0,
|
|
1062
|
+
right: 0,
|
|
1063
|
+
alignItems: 'center',
|
|
1064
|
+
},
|
|
1065
|
+
cameraHeaderText: {
|
|
1066
|
+
color: '#fff',
|
|
1067
|
+
fontSize: 18,
|
|
1068
|
+
fontWeight: 'bold',
|
|
1069
|
+
backgroundColor: 'rgba(0,0,0,0.7)',
|
|
1070
|
+
paddingHorizontal: 20,
|
|
1071
|
+
paddingVertical: 6,
|
|
1072
|
+
borderRadius: 16,
|
|
1073
|
+
},
|
|
1036
1074
|
});
|