react-native-expo-cropper 1.2.53 → 1.3.53
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/CustomCamera.js +164 -55
- package/dist/ImageCropper.js +131 -442
- package/dist/ImageCropperStyles.js +152 -182
- package/package.json +1 -1
- package/src/CustomCamera.js +147 -54
- package/src/ImageCropper.js +157 -458
- package/src/ImageCropperStyles.js +159 -199
package/src/ImageCropper.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import styles from './ImageCropperStyles';
|
|
2
2
|
import React, { useState, useRef, useEffect } from 'react';
|
|
3
|
-
import { Modal, View, Image, TouchableOpacity,
|
|
3
|
+
import { Modal, View, Image, TouchableOpacity, Text, Platform, PixelRatio, StyleSheet, ActivityIndicator, useWindowDimensions } from 'react-native';
|
|
4
4
|
import Svg, { Path, Circle } from 'react-native-svg';
|
|
5
5
|
import CustomCamera from './CustomCamera';
|
|
6
6
|
import * as ImageManipulator from 'expo-image-manipulator';
|
|
@@ -8,7 +8,8 @@ import { Ionicons } from '@expo/vector-icons';
|
|
|
8
8
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
9
9
|
import { applyMaskToImage, MaskView } from './ImageMaskProcessor';
|
|
10
10
|
|
|
11
|
-
const PRIMARY_GREEN = '#
|
|
11
|
+
const PRIMARY_GREEN = '#689F38';
|
|
12
|
+
const ACCENT_GREEN = '#689F38';
|
|
12
13
|
|
|
13
14
|
// Base dimension for responsive scaling (e.g. ~375pt design width)
|
|
14
15
|
const RESPONSIVE_BASE = 375;
|
|
@@ -20,8 +21,10 @@ const ImageCropper = ({
|
|
|
20
21
|
addheight,
|
|
21
22
|
rotationLabel,
|
|
22
23
|
onCancel,
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
cancelText = 'Cancel',
|
|
25
|
+
confirmText = 'Use Scan',
|
|
26
|
+
resetText = 'Enhance',
|
|
27
|
+
rotateText = 'Rotate',
|
|
25
28
|
cameraInstructionText = 'Place the tray inside the green box',
|
|
26
29
|
}) => {
|
|
27
30
|
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
|
@@ -559,425 +562,96 @@ const ImageCropper = ({
|
|
|
559
562
|
};
|
|
560
563
|
|
|
561
564
|
const handleTap = (e) => {
|
|
562
|
-
if (!image || showResult) return;
|
|
565
|
+
if (!image || showResult || points.length === 0) return;
|
|
563
566
|
const now = Date.now();
|
|
564
567
|
const { locationX: tapX, locationY: tapY } = e.nativeEvent;
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
// Les coordonnées du tap sont relatives au wrapper commun
|
|
568
|
-
let imageRect = imageDisplayRect.current;
|
|
569
|
-
const wrapper = commonWrapperLayout.current;
|
|
570
|
-
|
|
571
|
-
// Recalculate if not available
|
|
572
|
-
if (imageRect.width === 0 || imageRect.height === 0) {
|
|
573
|
-
if (wrapper.width > 0 && wrapper.height > 0 && originalImageDimensions.current.width > 0) {
|
|
574
|
-
updateImageDisplayRect(wrapper.width, wrapper.height);
|
|
575
|
-
imageRect = imageDisplayRect.current;
|
|
576
|
-
}
|
|
577
|
-
// If still not available, use full wrapper as fallback (wrapper-relative coords)
|
|
578
|
-
if (imageRect.width === 0 || imageRect.height === 0) {
|
|
579
|
-
if (wrapper.width > 0 && wrapper.height > 0) {
|
|
580
|
-
imageRect = {
|
|
581
|
-
x: 0,
|
|
582
|
-
y: 0,
|
|
583
|
-
width: wrapper.width,
|
|
584
|
-
height: wrapper.height,
|
|
585
|
-
};
|
|
586
|
-
} else {
|
|
587
|
-
console.warn("⚠️ Cannot handle tap: wrapper or imageDisplayRect not available");
|
|
588
|
-
return;
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
// ✅ Clamp to real displayed image content (imageDisplayRect dans le wrapper)
|
|
594
|
-
// Les coordonnées tapX/tapY et imageRect sont RELATIVES au wrapper commun (origine 0,0)
|
|
595
|
-
const { x: cx, y: cy, width: cw, height: ch } = {
|
|
596
|
-
x: imageRect.x,
|
|
597
|
-
y: imageRect.y,
|
|
598
|
-
width: imageRect.width,
|
|
599
|
-
height: imageRect.height,
|
|
600
|
-
};
|
|
601
|
-
// ✅ Responsive select radius: scales with screen so touch target matches handle size
|
|
602
|
-
const selectRadius = Math.max(28, Math.round(50 * responsiveScale));
|
|
603
|
-
|
|
604
|
-
// ✅ CRITICAL: Check for existing point selection FIRST (using raw tap coordinates)
|
|
605
|
-
// Don't clamp tapX/Y for point selection - points can be anywhere in wrapper now
|
|
606
|
-
const index = points.findIndex(p => Math.abs(p.x - tapX) < selectRadius && Math.abs(p.y - tapY) < selectRadius);
|
|
607
|
-
|
|
568
|
+
const selectRadius = Math.max(18, Math.round(26 * responsiveScale));
|
|
569
|
+
const index = points.findIndex((p) => Math.abs(p.x - tapX) < selectRadius && Math.abs(p.y - tapY) < selectRadius);
|
|
608
570
|
if (index !== -1) {
|
|
609
|
-
// ✅ Point found - select it for dragging
|
|
610
571
|
selectedPointIndex.current = index;
|
|
611
|
-
|
|
612
|
-
// Store initial positions
|
|
613
572
|
initialTouchPosition.current = { x: tapX, y: tapY };
|
|
614
573
|
lastTouchPosition.current = { x: tapX, y: tapY };
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
// Disable parent ScrollView scrolling when dragging
|
|
634
|
-
try {
|
|
635
|
-
const findScrollView = (node) => {
|
|
636
|
-
if (!node) return null;
|
|
637
|
-
if (node._component && node._component.setNativeProps) {
|
|
638
|
-
node._component.setNativeProps({ scrollEnabled: false });
|
|
639
|
-
}
|
|
640
|
-
return findScrollView(node._owner || node._parent);
|
|
641
|
-
};
|
|
642
|
-
} catch (e) {
|
|
643
|
-
// Ignore errors
|
|
644
|
-
}
|
|
645
|
-
} else {
|
|
646
|
-
// ✅ No point found - check if double-tap on a line to create new point
|
|
647
|
-
const isDoubleTap = lastTap.current && now - lastTap.current < 300;
|
|
648
|
-
|
|
649
|
-
if (isDoubleTap && points.length >= 2) {
|
|
650
|
-
// Find closest point on frame lines
|
|
651
|
-
const lineResult = findClosestPointOnFrame(tapX, tapY, 30); // 30px tolerance
|
|
652
|
-
|
|
653
|
-
if (lineResult) {
|
|
654
|
-
const { point, insertIndex } = lineResult;
|
|
655
|
-
|
|
656
|
-
// Check if a point already exists very close to this position
|
|
657
|
-
const exists = points.some(p => Math.abs(p.x - point.x) < selectRadius && Math.abs(p.y - point.y) < selectRadius);
|
|
658
|
-
|
|
659
|
-
if (!exists) {
|
|
660
|
-
// Insert new point at the correct position in the polygon
|
|
661
|
-
const newPoints = [...points];
|
|
662
|
-
newPoints.splice(insertIndex, 0, point);
|
|
663
|
-
setPoints(newPoints);
|
|
664
|
-
|
|
665
|
-
console.log("✅ New point created on frame line:", {
|
|
666
|
-
tap: { x: tapX.toFixed(2), y: tapY.toFixed(2) },
|
|
667
|
-
newPoint: { x: point.x.toFixed(2), y: point.y.toFixed(2) },
|
|
668
|
-
insertIndex,
|
|
669
|
-
totalPoints: newPoints.length
|
|
670
|
-
});
|
|
671
|
-
|
|
672
|
-
lastTap.current = null; // Reset to prevent triple-tap
|
|
673
|
-
return;
|
|
674
|
-
}
|
|
574
|
+
lastTap.current = now;
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Restore UX: double tap near an edge inserts a new draggable point.
|
|
579
|
+
const isDoubleTap = lastTap.current && now - lastTap.current < 300;
|
|
580
|
+
if (isDoubleTap && points.length >= 2) {
|
|
581
|
+
const lineResult = findClosestPointOnFrame(tapX, tapY, 30);
|
|
582
|
+
if (lineResult) {
|
|
583
|
+
const { point, insertIndex } = lineResult;
|
|
584
|
+
const exists = points.some((p) => Math.abs(p.x - point.x) < selectRadius && Math.abs(p.y - point.y) < selectRadius);
|
|
585
|
+
if (!exists) {
|
|
586
|
+
const newPoints = [...points];
|
|
587
|
+
newPoints.splice(insertIndex, 0, point);
|
|
588
|
+
setPoints(newPoints);
|
|
589
|
+
lastTap.current = null;
|
|
590
|
+
return;
|
|
675
591
|
}
|
|
676
592
|
}
|
|
677
593
|
}
|
|
678
|
-
|
|
594
|
+
|
|
679
595
|
lastTap.current = now;
|
|
680
596
|
};
|
|
681
597
|
|
|
682
598
|
const handleMove = (e) => {
|
|
683
599
|
if (showResult || selectedPointIndex.current === null) return;
|
|
684
|
-
|
|
685
|
-
// ✅ FREE DRAG: Use delta-based movement for smooth, unconstrained dragging
|
|
686
|
-
|
|
600
|
+
|
|
687
601
|
const nativeEvent = e.nativeEvent;
|
|
688
602
|
const currentX = nativeEvent.locationX;
|
|
689
603
|
const currentY = nativeEvent.locationY;
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
locationX: nativeEvent.locationX,
|
|
695
|
-
locationY: nativeEvent.locationY
|
|
696
|
-
});
|
|
697
|
-
return;
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
// This is more reliable when ScrollView affects coordinate updates
|
|
701
|
-
let deltaX, deltaY;
|
|
604
|
+
if (currentX === undefined || currentY === undefined || isNaN(currentX) || isNaN(currentY)) return;
|
|
605
|
+
|
|
606
|
+
let deltaX = 0;
|
|
607
|
+
let deltaY = 0;
|
|
702
608
|
if (lastTouchPosition.current) {
|
|
703
|
-
// Calculate incremental delta from last touch position
|
|
704
609
|
deltaX = currentX - lastTouchPosition.current.x;
|
|
705
610
|
deltaY = currentY - lastTouchPosition.current.y;
|
|
706
611
|
} else if (initialTouchPosition.current) {
|
|
707
|
-
// Fallback to absolute delta if lastTouchPosition not set
|
|
708
612
|
deltaX = currentX - initialTouchPosition.current.x;
|
|
709
613
|
deltaY = currentY - initialTouchPosition.current.y;
|
|
710
|
-
} else {
|
|
711
|
-
console.warn("⚠️ No touch position reference available");
|
|
712
|
-
return;
|
|
713
614
|
}
|
|
714
615
|
|
|
715
|
-
// Les coordonnées de mouvement sont relatives au wrapper commun
|
|
716
616
|
let imageRect = imageDisplayRect.current;
|
|
717
617
|
const wrapper = commonWrapperLayout.current;
|
|
718
|
-
|
|
719
|
-
// Recalculate if not available
|
|
720
618
|
if (imageRect.width === 0 || imageRect.height === 0) {
|
|
721
619
|
if (wrapper.width > 0 && wrapper.height > 0 && originalImageDimensions.current.width > 0) {
|
|
722
620
|
updateImageDisplayRect(wrapper.width, wrapper.height);
|
|
723
621
|
imageRect = imageDisplayRect.current;
|
|
724
622
|
}
|
|
725
|
-
// If still not available, use full wrapper as fallback (wrapper-relative coords)
|
|
726
623
|
if (imageRect.width === 0 || imageRect.height === 0) {
|
|
727
|
-
|
|
728
|
-
imageRect = {
|
|
729
|
-
x: 0,
|
|
730
|
-
y: 0,
|
|
731
|
-
width: wrapper.width,
|
|
732
|
-
height: wrapper.height,
|
|
733
|
-
};
|
|
734
|
-
} else {
|
|
735
|
-
console.warn("⚠️ Cannot move point: wrapper or imageDisplayRect not available");
|
|
736
|
-
return;
|
|
737
|
-
}
|
|
624
|
+
imageRect = { x: 0, y: 0, width: wrapper.width || 0, height: wrapper.height || 0 };
|
|
738
625
|
}
|
|
739
626
|
}
|
|
740
|
-
|
|
741
|
-
// ✅ Bounds in wrapper-relative coordinates (touch/points use wrapper as origin)
|
|
742
|
-
// Full picture = entire wrapper; visible image = imageDisplayRect
|
|
743
|
-
const contentRect = {
|
|
744
|
-
x: imageRect.x,
|
|
745
|
-
y: imageRect.y,
|
|
746
|
-
width: imageRect.width,
|
|
747
|
-
height: imageRect.height
|
|
748
|
-
};
|
|
749
|
-
|
|
750
|
-
// ✅ FREE DRAG: Ensure initial positions are set
|
|
751
|
-
if (!initialPointPosition.current) {
|
|
752
|
-
const currentPoint = points[selectedPointIndex.current];
|
|
753
|
-
if (currentPoint && typeof currentPoint.x === 'number' && typeof currentPoint.y === 'number') {
|
|
754
|
-
initialPointPosition.current = { ...currentPoint };
|
|
755
|
-
} else {
|
|
756
|
-
console.warn("⚠️ No point found for selected index or invalid point data");
|
|
757
|
-
return;
|
|
758
|
-
}
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
// ✅ NEW APPROACH: Use touchOffset to map touch position directly to point position
|
|
762
|
-
// This eliminates delta accumulation and "dead zone" issues completely
|
|
763
|
-
if (!touchOffset.current) {
|
|
764
|
-
console.warn("⚠️ touchOffset not initialized, cannot move point");
|
|
765
|
-
return;
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
// ✅ DIRECT MAPPING: newPosition = touchPosition + offset
|
|
769
|
-
// No delta accumulation, no zone morte
|
|
770
|
-
const newX = currentX + touchOffset.current.x;
|
|
771
|
-
const newY = currentY + touchOffset.current.y;
|
|
772
|
-
|
|
773
|
-
// ✅ STRICT BOUNDS: visible image area (wrapper-relative) for lastValidPosition
|
|
774
|
-
const strictMinX = contentRect.x;
|
|
775
|
-
const strictMaxX = contentRect.x + contentRect.width;
|
|
776
|
-
// Tiny safety margin at bottom (2px) so point can almost reach bottom edge
|
|
777
|
-
const bottomSafePadding = 2;
|
|
778
|
-
const strictMinY = contentRect.y;
|
|
779
|
-
const strictMaxY = contentRect.y + contentRect.height - bottomSafePadding;
|
|
780
|
-
|
|
781
|
-
// ✅ DRAG BOUNDS: keep points strictly on the visible picture (no going outside image)
|
|
782
|
-
const overshootMinX = strictMinX;
|
|
783
|
-
const overshootMaxX = strictMaxX;
|
|
784
|
-
const overshootMinY = strictMinY;
|
|
785
|
-
const overshootMaxY = strictMaxY;
|
|
786
|
-
|
|
787
|
-
// ✅ Clamp to visible image so points always stay on the picture
|
|
788
|
-
const dragX = Math.max(overshootMinX, Math.min(newX, overshootMaxX));
|
|
789
|
-
let dragY = Math.max(overshootMinY, Math.min(newY, overshootMaxY));
|
|
790
|
-
|
|
791
|
-
// ✅ Bottom-band "stickiness": if the point is already in the last few pixels
|
|
792
|
-
// near the bottom, ignore any move that would suddenly pull it back up.
|
|
793
|
-
const bottomBandThreshold = strictMaxY - 3; // last ~3px of allowed area
|
|
794
|
-
const prevPoint = points[selectedPointIndex.current];
|
|
795
|
-
if (prevPoint && prevPoint.y >= bottomBandThreshold && dragY < prevPoint.y) {
|
|
796
|
-
dragY = prevPoint.y;
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
// ✅ UPDATE POINT: Use (possibly adjusted) dragY
|
|
800
|
-
const updatedPoint = { x: dragX, y: dragY };
|
|
801
|
-
|
|
802
|
-
// ✅ CRITICAL: Detect if point is AT overshoot boundary (not just clamped)
|
|
803
|
-
// Check if point is exactly at overshootMin/Max (within 1px tolerance)
|
|
804
|
-
const isAtOvershootMinX = Math.abs(dragX - overshootMinX) < 1;
|
|
805
|
-
const isAtOvershootMaxX = Math.abs(dragX - overshootMaxX) < 1;
|
|
806
|
-
const isAtOvershootMinY = Math.abs(dragY - overshootMinY) < 1;
|
|
807
|
-
const isAtOvershootMaxY = Math.abs(dragY - overshootMaxY) < 1;
|
|
808
|
-
|
|
809
|
-
const isAtBoundaryX = isAtOvershootMinX || isAtOvershootMaxX;
|
|
810
|
-
const isAtBoundaryY = isAtOvershootMinY || isAtOvershootMaxY;
|
|
811
|
-
|
|
812
|
-
// Only recalculate offset when FIRST hitting boundary (transition free → boundary)
|
|
813
|
-
const justHitBoundaryX = isAtBoundaryX && !wasClampedLastFrame.current.x;
|
|
814
|
-
const justHitBoundaryY = isAtBoundaryY && !wasClampedLastFrame.current.y;
|
|
815
|
-
|
|
816
|
-
if (justHitBoundaryX || justHitBoundaryY) {
|
|
817
|
-
// Point JUST hit overshoot boundary - recalculate offset once
|
|
818
|
-
const newOffsetX = justHitBoundaryX ? (dragX - currentX) : touchOffset.current.x;
|
|
819
|
-
const newOffsetY = justHitBoundaryY ? (dragY - currentY) : touchOffset.current.y;
|
|
820
|
-
|
|
821
|
-
touchOffset.current = {
|
|
822
|
-
x: newOffsetX,
|
|
823
|
-
y: newOffsetY
|
|
824
|
-
};
|
|
825
|
-
|
|
826
|
-
console.log("✅ OFFSET RECALCULATED (hit boundary):", {
|
|
827
|
-
axis: justHitBoundaryX ? 'X' : 'Y',
|
|
828
|
-
touchY: currentY.toFixed(2),
|
|
829
|
-
dragY: dragY.toFixed(2),
|
|
830
|
-
newOffsetY: touchOffset.current.y.toFixed(2),
|
|
831
|
-
note: "First contact with boundary - offset locked"
|
|
832
|
-
});
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
// Update boundary state for next frame
|
|
836
|
-
wasClampedLastFrame.current = { x: isAtBoundaryX, y: isAtBoundaryY };
|
|
837
|
-
|
|
838
|
-
// ✅ DEBUG: Log when in overshoot zone (only when not at boundary)
|
|
839
|
-
const isInOvershootY = dragY < strictMinY || dragY > strictMaxY;
|
|
840
|
-
if (isInOvershootY && !isAtBoundaryY) {
|
|
841
|
-
console.log("🎯 IN OVERSHOOT ZONE:", {
|
|
842
|
-
touchY: currentY.toFixed(2),
|
|
843
|
-
appliedY: dragY.toFixed(2),
|
|
844
|
-
overshootRange: `${overshootMinY.toFixed(2)} - ${overshootMaxY.toFixed(2)}`,
|
|
845
|
-
strictRange: `${strictMinY.toFixed(2)} - ${strictMaxY.toFixed(2)}`
|
|
846
|
-
});
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
// ✅ Update lastValidPosition ONLY if point is within strictBounds
|
|
850
|
-
const isStrictlyValid =
|
|
851
|
-
dragX >= strictMinX && dragX <= strictMaxX &&
|
|
852
|
-
dragY >= strictMinY && dragY <= strictMaxY;
|
|
853
|
-
|
|
854
|
-
if (isStrictlyValid) {
|
|
855
|
-
lastValidPosition.current = updatedPoint;
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
// ✅ Update lastTouchPosition for next frame (simple tracking)
|
|
627
|
+
|
|
859
628
|
lastTouchPosition.current = { x: currentX, y: currentY };
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
index: selectedPointIndex.current,
|
|
864
|
-
newX: updatedPoint.x.toFixed(2),
|
|
865
|
-
newY: updatedPoint.y.toFixed(2),
|
|
866
|
-
touchX: currentX.toFixed(2),
|
|
867
|
-
touchY: currentY.toFixed(2),
|
|
868
|
-
offsetX: touchOffset.current.x.toFixed(2),
|
|
869
|
-
offsetY: touchOffset.current.y.toFixed(2)
|
|
870
|
-
});
|
|
871
|
-
|
|
872
|
-
setPoints(prev => {
|
|
873
|
-
// ✅ SAFETY: Ensure prev is a valid array
|
|
874
|
-
if (!Array.isArray(prev) || prev.length === 0) {
|
|
875
|
-
return prev;
|
|
876
|
-
}
|
|
877
|
-
|
|
629
|
+
|
|
630
|
+
setPoints((prev) => {
|
|
631
|
+
if (!Array.isArray(prev) || prev.length === 0) return prev;
|
|
878
632
|
const pointIndex = selectedPointIndex.current;
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
return updatedPoint;
|
|
888
|
-
}
|
|
889
|
-
// ✅ SAFETY: Ensure existing points are valid
|
|
890
|
-
if (p && typeof p.x === 'number' && typeof p.y === 'number') {
|
|
891
|
-
return p;
|
|
892
|
-
}
|
|
893
|
-
// If point is invalid, return a default point (shouldn't happen, but safety first)
|
|
894
|
-
return { x: 0, y: 0 };
|
|
895
|
-
});
|
|
896
|
-
|
|
897
|
-
// ✅ DEBUG: Log the state update
|
|
898
|
-
console.log("✅ STATE UPDATED:", {
|
|
899
|
-
index: pointIndex,
|
|
900
|
-
oldY: prev[pointIndex]?.y.toFixed(2),
|
|
901
|
-
newY: newPoints[pointIndex]?.y.toFixed(2),
|
|
902
|
-
changed: Math.abs(prev[pointIndex]?.y - newPoints[pointIndex]?.y) > 0.01
|
|
633
|
+
if (pointIndex === null || pointIndex < 0 || pointIndex >= prev.length) return prev;
|
|
634
|
+
|
|
635
|
+
return prev.map((p, i) => {
|
|
636
|
+
if (i !== pointIndex) return p;
|
|
637
|
+
return {
|
|
638
|
+
x: Math.max(imageRect.x, Math.min(p.x + deltaX, imageRect.x + imageRect.width)),
|
|
639
|
+
y: Math.max(imageRect.y, Math.min(p.y + deltaY, imageRect.y + imageRect.height)),
|
|
640
|
+
};
|
|
903
641
|
});
|
|
904
|
-
|
|
905
|
-
return newPoints;
|
|
906
642
|
});
|
|
907
643
|
};
|
|
908
644
|
|
|
909
645
|
const handleRelease = () => {
|
|
910
646
|
const wasDragging = selectedPointIndex.current !== null;
|
|
911
|
-
|
|
912
|
-
// ✅ CRITICAL: Reset drag state when drag ends
|
|
913
|
-
touchOffset.current = null;
|
|
914
|
-
wasClampedLastFrame.current = { x: false, y: false };
|
|
915
|
-
|
|
916
|
-
// ✅ VISUAL OVERSHOOT: Clamp points back to imageDisplayRect when drag ends
|
|
917
|
-
// This ensures final crop is always within valid image bounds
|
|
918
|
-
if (wasDragging && selectedPointIndex.current !== null) {
|
|
919
|
-
const wrapper = commonWrapperLayout.current;
|
|
920
|
-
let imageRect = imageDisplayRect.current;
|
|
921
|
-
|
|
922
|
-
// Recalculate imageDisplayRect if needed
|
|
923
|
-
if (imageRect.width === 0 || imageRect.height === 0) {
|
|
924
|
-
if (wrapper.width > 0 && wrapper.height > 0 && originalImageDimensions.current.width > 0) {
|
|
925
|
-
updateImageDisplayRect(wrapper.width, wrapper.height);
|
|
926
|
-
imageRect = imageDisplayRect.current;
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
if (imageRect.width > 0 && imageRect.height > 0) {
|
|
931
|
-
// Bounds in wrapper-relative coords (same as point coords)
|
|
932
|
-
const minX = imageRect.x;
|
|
933
|
-
const minY = imageRect.y;
|
|
934
|
-
const maxX = imageRect.x + imageRect.width;
|
|
935
|
-
const maxY = imageRect.y + imageRect.height;
|
|
936
|
-
|
|
937
|
-
// Clamp the dragged point back to visible image area (full picture in wrapper-relative coords)
|
|
938
|
-
setPoints(prev => {
|
|
939
|
-
// ✅ SAFETY: Ensure prev is a valid array
|
|
940
|
-
if (!Array.isArray(prev) || prev.length === 0) {
|
|
941
|
-
return prev;
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
const pointIndex = selectedPointIndex.current;
|
|
945
|
-
// ✅ SAFETY: Validate pointIndex and ensure point exists
|
|
946
|
-
if (pointIndex === null || pointIndex === undefined || pointIndex < 0 || pointIndex >= prev.length) {
|
|
947
|
-
return prev;
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
const point = prev[pointIndex];
|
|
951
|
-
// ✅ SAFETY: Ensure point exists and has valid x/y properties
|
|
952
|
-
if (!point || typeof point.x !== 'number' || typeof point.y !== 'number') {
|
|
953
|
-
return prev;
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
const clampedPoint = {
|
|
957
|
-
x: Math.max(minX, Math.min(point.x, maxX)),
|
|
958
|
-
y: Math.max(minY, Math.min(point.y, maxY))
|
|
959
|
-
};
|
|
960
|
-
|
|
961
|
-
// Only update if point was outside bounds
|
|
962
|
-
if (point.x !== clampedPoint.x || point.y !== clampedPoint.y) {
|
|
963
|
-
console.log("🔒 Clamping point back to image bounds on release:", {
|
|
964
|
-
before: { x: point.x.toFixed(2), y: point.y.toFixed(2) },
|
|
965
|
-
after: { x: clampedPoint.x.toFixed(2), y: clampedPoint.y.toFixed(2) },
|
|
966
|
-
bounds: { minX: minX.toFixed(2), maxX: maxX.toFixed(2), minY: minY.toFixed(2), maxY: maxY.toFixed(2) }
|
|
967
|
-
});
|
|
968
|
-
|
|
969
|
-
return prev.map((p, i) => i === pointIndex ? clampedPoint : p);
|
|
970
|
-
}
|
|
971
|
-
|
|
972
|
-
return prev;
|
|
973
|
-
});
|
|
974
|
-
}
|
|
975
|
-
}
|
|
976
|
-
|
|
647
|
+
|
|
977
648
|
// ✅ FREE DRAG: Clear initial positions when drag ends
|
|
978
649
|
initialTouchPosition.current = null;
|
|
979
650
|
initialPointPosition.current = null;
|
|
651
|
+
lastTouchPosition.current = null;
|
|
980
652
|
lastValidPosition.current = null;
|
|
653
|
+
touchOffset.current = null;
|
|
654
|
+
wasClampedLastFrame.current = { x: false, y: false };
|
|
981
655
|
selectedPointIndex.current = null;
|
|
982
656
|
|
|
983
657
|
// ✅ CRITICAL: Re-enable parent ScrollView scrolling when drag ends
|
|
@@ -1058,6 +732,18 @@ const rotatePreviewImage = async (degrees) => {
|
|
|
1058
732
|
});
|
|
1059
733
|
};
|
|
1060
734
|
|
|
735
|
+
const getFrameBounds = () => {
|
|
736
|
+
if (!points || points.length < 4) return null;
|
|
737
|
+
const xs = points.map((p) => p.x);
|
|
738
|
+
const ys = points.map((p) => p.y);
|
|
739
|
+
return {
|
|
740
|
+
minX: Math.min(...xs),
|
|
741
|
+
maxX: Math.max(...xs),
|
|
742
|
+
minY: Math.min(...ys),
|
|
743
|
+
maxY: Math.max(...ys),
|
|
744
|
+
};
|
|
745
|
+
};
|
|
746
|
+
|
|
1061
747
|
|
|
1062
748
|
return (
|
|
1063
749
|
<View style={styles.container}>
|
|
@@ -1181,7 +867,7 @@ const rotatePreviewImage = async (degrees) => {
|
|
|
1181
867
|
const sizeScale = Math.max(0.6, Math.min(1.4, sizeScaleRaw)); // clamp
|
|
1182
868
|
|
|
1183
869
|
const stroke = Math.max(0.75, 2 * responsiveScale * sizeScale);
|
|
1184
|
-
const handleRadius = Math.max(
|
|
870
|
+
const handleRadius = Math.max(5, 9 * responsiveScale * sizeScale);
|
|
1185
871
|
|
|
1186
872
|
return (
|
|
1187
873
|
<>
|
|
@@ -1191,15 +877,31 @@ const rotatePreviewImage = async (degrees) => {
|
|
|
1191
877
|
fillRule="evenodd"
|
|
1192
878
|
/>
|
|
1193
879
|
{!showResult && points.length > 0 && (
|
|
1194
|
-
<Path d={createPath()} fill="transparent" stroke="
|
|
880
|
+
<Path d={createPath()} fill="transparent" stroke="rgba(255,255,255,0.95)" strokeWidth={stroke} />
|
|
1195
881
|
)}
|
|
1196
882
|
{!showResult && points.map((point, index) => (
|
|
1197
|
-
<Circle key={index} cx={point.x} cy={point.y} r={handleRadius} fill="
|
|
883
|
+
<Circle key={`dot-${index}`} cx={point.x} cy={point.y} r={handleRadius} fill="#E7FFD9" stroke={ACCENT_GREEN} strokeWidth={1.5} />
|
|
1198
884
|
))}
|
|
1199
885
|
</>
|
|
1200
886
|
);
|
|
1201
887
|
})()}
|
|
1202
888
|
</Svg>
|
|
889
|
+
{!showResult && image && (
|
|
890
|
+
<>
|
|
891
|
+
{/* <View style={[styles.topBar, { paddingTop: Math.max(insets.top + 8, 18) }]}>
|
|
892
|
+
<TouchableOpacity style={styles.topIconButton} activeOpacity={0.8}>
|
|
893
|
+
<Ionicons name="flash" size={18} color={ACCENT_GREEN} />
|
|
894
|
+
</TouchableOpacity>
|
|
895
|
+
<Text style={[styles.topBarTitle, { fontSize: Math.max(22, Math.round(30 * responsiveScale)) }]}>Precision Scan</Text>
|
|
896
|
+
<TouchableOpacity style={styles.topIconButton} onPress={handleReset} activeOpacity={0.8}>
|
|
897
|
+
<Ionicons name="settings-outline" size={20} color="white" />
|
|
898
|
+
</TouchableOpacity>
|
|
899
|
+
</View> */}
|
|
900
|
+
{/* <View style={styles.detectBadge}>
|
|
901
|
+
<Text style={styles.detectBadgeText}>A4 Document Detected</Text>
|
|
902
|
+
</View> */}
|
|
903
|
+
</>
|
|
904
|
+
)}
|
|
1203
905
|
</View>
|
|
1204
906
|
{isRotating && (
|
|
1205
907
|
<View style={{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'center', alignItems: 'center' }}>
|
|
@@ -1217,90 +919,91 @@ const rotatePreviewImage = async (degrees) => {
|
|
|
1217
919
|
style={[
|
|
1218
920
|
styles.buttonContainerBelow,
|
|
1219
921
|
{
|
|
1220
|
-
|
|
1221
|
-
|
|
922
|
+
// Keep controls clearly above home indicator / bottom gesture bar.
|
|
923
|
+
paddingBottom: Math.max(insets.bottom + Math.round(12 * responsiveScale), Math.round(20 * responsiveScale)),
|
|
924
|
+
paddingTop: Math.round(10 * responsiveScale),
|
|
1222
925
|
paddingHorizontal: Math.round(12 * responsiveScale),
|
|
1223
|
-
gap: Math.round(8 * responsiveScale),
|
|
1224
926
|
},
|
|
1225
927
|
]}
|
|
1226
928
|
>
|
|
1227
|
-
{
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
}
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
929
|
+
<View style={styles.controlsRow}>
|
|
930
|
+
<TouchableOpacity
|
|
931
|
+
style={[
|
|
932
|
+
styles.roundControl,
|
|
933
|
+
{
|
|
934
|
+
width: 'auto',
|
|
935
|
+
minWidth: Math.max(
|
|
936
|
+
Math.round(84 * responsiveScale),
|
|
937
|
+
Math.round(((cancelText ? String(cancelText).length : 6) * 7 + 32) * responsiveScale)
|
|
938
|
+
),
|
|
939
|
+
paddingHorizontal: Math.round(12 * responsiveScale),
|
|
940
|
+
},
|
|
941
|
+
]}
|
|
942
|
+
onPress={() => {
|
|
943
|
+
if (onCancel) {
|
|
944
|
+
onCancel();
|
|
945
|
+
}
|
|
946
|
+
}}
|
|
947
|
+
activeOpacity={0.85}
|
|
948
|
+
>
|
|
949
|
+
<Text
|
|
950
|
+
style={[
|
|
951
|
+
styles.buttonText,
|
|
952
|
+
{
|
|
953
|
+
fontSize: Math.max(10, Math.round(11 * responsiveScale)),
|
|
954
|
+
letterSpacing: 0.5,
|
|
955
|
+
},
|
|
956
|
+
]}
|
|
957
|
+
>
|
|
958
|
+
{cancelText}
|
|
959
|
+
</Text>
|
|
960
|
+
</TouchableOpacity>
|
|
961
|
+
<TouchableOpacity
|
|
962
|
+
style={styles.enhanceButton}
|
|
963
|
+
onPress={handleReset}
|
|
964
|
+
activeOpacity={0.85}
|
|
965
|
+
>
|
|
966
|
+
<Ionicons name="sparkles-outline" size={Math.round(18 * responsiveScale)} color="rgba(255,255,255,0.95)" />
|
|
967
|
+
<Text style={styles.buttonText} numberOfLines={1} ellipsizeMode="tail">
|
|
968
|
+
{resetText || 'Enhance'}
|
|
969
|
+
</Text>
|
|
970
|
+
</TouchableOpacity>
|
|
971
|
+
<TouchableOpacity
|
|
1252
972
|
style={[
|
|
1253
973
|
styles.rotationButton,
|
|
1254
974
|
{
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
975
|
+
minWidth: Math.max(
|
|
976
|
+
Math.round(88 * responsiveScale),
|
|
977
|
+
Math.round(((rotateText ? String(rotateText).length : 6) * 7 + 38) * responsiveScale)
|
|
978
|
+
),
|
|
1258
979
|
},
|
|
1259
980
|
isRotating && { opacity: 0.7 },
|
|
1260
|
-
]}
|
|
981
|
+
]}
|
|
1261
982
|
onPress={() => enableRotation && rotatePreviewImage(90)}
|
|
1262
983
|
disabled={isRotating}
|
|
984
|
+
activeOpacity={0.85}
|
|
1263
985
|
>
|
|
1264
986
|
{isRotating ? (
|
|
1265
987
|
<ActivityIndicator size="small" color="white" />
|
|
1266
988
|
) : (
|
|
1267
|
-
<
|
|
989
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
|
990
|
+
<Ionicons name="refresh" size={Math.round(15 * responsiveScale)} color="white" />
|
|
991
|
+
<Text
|
|
992
|
+
style={[
|
|
993
|
+
styles.buttonText,
|
|
994
|
+
{
|
|
995
|
+
fontSize: Math.max(10, Math.round(11 * responsiveScale)),
|
|
996
|
+
letterSpacing: 0.5,
|
|
997
|
+
},
|
|
998
|
+
]}
|
|
999
|
+
>
|
|
1000
|
+
{rotateText}
|
|
1001
|
+
</Text>
|
|
1002
|
+
</View>
|
|
1268
1003
|
)}
|
|
1269
1004
|
</TouchableOpacity>
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
<TouchableOpacity
|
|
1273
|
-
style={[
|
|
1274
|
-
styles.button,
|
|
1275
|
-
{
|
|
1276
|
-
minHeight: Math.round(48 * responsiveScale),
|
|
1277
|
-
paddingVertical: Math.round(12 * responsiveScale),
|
|
1278
|
-
paddingHorizontal: Math.round(10 * responsiveScale),
|
|
1279
|
-
},
|
|
1280
|
-
]}
|
|
1281
|
-
onPress={handleReset}
|
|
1282
|
-
>
|
|
1283
|
-
<Text
|
|
1284
|
-
style={[
|
|
1285
|
-
styles.buttonText,
|
|
1286
|
-
{
|
|
1287
|
-
// Smaller font for longer labels so they stay on one line
|
|
1288
|
-
fontSize: Math.max(
|
|
1289
|
-
12,
|
|
1290
|
-
Math.round(
|
|
1291
|
-
(resetText && resetText.length > 10 ? 14 : 18) *
|
|
1292
|
-
responsiveScale
|
|
1293
|
-
)
|
|
1294
|
-
),
|
|
1295
|
-
},
|
|
1296
|
-
]}
|
|
1297
|
-
numberOfLines={1}
|
|
1298
|
-
ellipsizeMode="tail"
|
|
1299
|
-
>
|
|
1300
|
-
{resetText}
|
|
1301
|
-
</Text>
|
|
1302
|
-
</TouchableOpacity>
|
|
1303
|
-
|
|
1005
|
+
</View>
|
|
1006
|
+
|
|
1304
1007
|
<TouchableOpacity
|
|
1305
1008
|
onPress={async () => {
|
|
1306
1009
|
setIsLoading(true);
|
|
@@ -1435,30 +1138,26 @@ const rotatePreviewImage = async (degrees) => {
|
|
|
1435
1138
|
style={[
|
|
1436
1139
|
styles.button,
|
|
1437
1140
|
{
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1141
|
+
marginTop: Math.round(10 * responsiveScale),
|
|
1142
|
+
// Keep confirm action above browser/system navigation bars.
|
|
1143
|
+
marginBottom: Math.max(insets.bottom + Math.round(10 * responsiveScale), Math.round(22 * responsiveScale)),
|
|
1144
|
+
borderWidth: 1.8,
|
|
1145
|
+
borderColor: ACCENT_GREEN,
|
|
1441
1146
|
},
|
|
1442
1147
|
]}
|
|
1148
|
+
hitSlop={{ top: 8, left: 8, right: 8, bottom: 12 }}
|
|
1443
1149
|
>
|
|
1444
1150
|
<Text
|
|
1445
1151
|
style={[
|
|
1446
1152
|
styles.buttonText,
|
|
1447
1153
|
{
|
|
1448
|
-
|
|
1449
|
-
fontSize: Math.max(
|
|
1450
|
-
12,
|
|
1451
|
-
Math.round(
|
|
1452
|
-
(confirmText && confirmText.length > 10 ? 14 : 18) *
|
|
1453
|
-
responsiveScale
|
|
1454
|
-
)
|
|
1455
|
-
),
|
|
1154
|
+
fontSize: Math.max(13, Math.round(14 * responsiveScale)),
|
|
1456
1155
|
},
|
|
1457
1156
|
]}
|
|
1458
1157
|
numberOfLines={1}
|
|
1459
1158
|
ellipsizeMode="tail"
|
|
1460
1159
|
>
|
|
1461
|
-
{confirmText}
|
|
1160
|
+
{confirmText || 'Use Scan'}
|
|
1462
1161
|
</Text>
|
|
1463
1162
|
</TouchableOpacity>
|
|
1464
1163
|
</View>
|