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.
- package/dist/DocScanner.js +31 -1
- package/dist/FullDocScanner.d.ts +2 -0
- package/dist/FullDocScanner.js +36 -48
- package/package.json +1 -1
- package/src/DocScanner.tsx +33 -1
- package/src/FullDocScanner.tsx +39 -54
package/dist/DocScanner.js
CHANGED
|
@@ -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
|
-
|
|
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, () => ({
|
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 degrees (0, 90, 180, 270) */
|
|
12
|
+
rotation?: number;
|
|
11
13
|
}
|
|
12
14
|
export interface FullDocScannerStrings {
|
|
13
15
|
captureHint?: string;
|
package/dist/FullDocScanner.js
CHANGED
|
@@ -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 [
|
|
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)(
|
|
333
|
-
if (
|
|
332
|
+
const handleRotateImage = (0, react_1.useCallback)((degrees) => {
|
|
333
|
+
if (!croppedImageData)
|
|
334
334
|
return;
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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.
|
|
451
|
-
react_1.default.createElement(react_native_1.TouchableOpacity, { style:
|
|
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:
|
|
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:
|
|
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
package/src/DocScanner.tsx
CHANGED
|
@@ -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
|
-
|
|
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],
|
package/src/FullDocScanner.tsx
CHANGED
|
@@ -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 [
|
|
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(
|
|
444
|
-
if (
|
|
445
|
+
const handleRotateImage = useCallback((degrees: -90 | 90) => {
|
|
446
|
+
if (!croppedImageData) return;
|
|
445
447
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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.
|
|
550
|
+
{/* 회전 버튼들 - 가운데 정렬 */}
|
|
551
|
+
<View style={styles.rotateButtonsCenter}>
|
|
579
552
|
<TouchableOpacity
|
|
580
|
-
style={
|
|
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={
|
|
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={
|
|
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',
|