react-native-rectangle-doc-scanner 3.27.0 → 3.29.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.
@@ -83,17 +83,22 @@ const CropEditor = ({ document, overlayColor = 'rgba(0,0,0,0.5)', overlayStrokeC
83
83
  }, [document]);
84
84
  // Get initial rectangle from detected quad or use default
85
85
  const getInitialRectangle = (0, react_1.useCallback)(() => {
86
- if (!document.quad || !imageSize) {
86
+ if (!imageSize) {
87
87
  return undefined;
88
88
  }
89
- const rect = (0, coordinate_1.quadToRectangle)(document.quad);
90
- if (!rect) {
89
+ const baseWidth = document.width > 0 ? document.width : imageSize.width;
90
+ const baseHeight = document.height > 0 ? document.height : imageSize.height;
91
+ const sourceRectangle = document.rectangle
92
+ ? document.rectangle
93
+ : document.quad && document.quad.length === 4
94
+ ? (0, coordinate_1.quadToRectangle)(document.quad)
95
+ : null;
96
+ if (!sourceRectangle) {
91
97
  return undefined;
92
98
  }
93
- // Scale from original detection coordinates to image coordinates
94
- const scaled = (0, coordinate_1.scaleRectangle)(rect, document.width, document.height, imageSize.width, imageSize.height);
99
+ const scaled = (0, coordinate_1.scaleRectangle)(sourceRectangle, baseWidth, baseHeight, imageSize.width, imageSize.height);
95
100
  return scaled;
96
- }, [document.quad, document.width, document.height, imageSize]);
101
+ }, [document.rectangle, document.quad, document.width, document.height, imageSize]);
97
102
  const handleImageLoad = (0, react_1.useCallback)((event) => {
98
103
  // This is just for debugging - actual size is loaded via Image.getSize in useEffect
99
104
  console.log('[CropEditor] Image onLoad event triggered');
@@ -17,6 +17,7 @@ export type DocScannerCapture = {
17
17
  initialPath: string | null;
18
18
  croppedPath: string | null;
19
19
  quad: Point[] | null;
20
+ rectangle: Rectangle | null;
20
21
  width: number;
21
22
  height: number;
22
23
  };
@@ -73,6 +73,7 @@ exports.DocScanner = (0, react_1.forwardRef)(({ onCapture, overlayColor = DEFAUL
73
73
  const captureResolvers = (0, react_1.useRef)(null);
74
74
  const [isAutoCapturing, setIsAutoCapturing] = (0, react_1.useState)(false);
75
75
  const [detectedRectangle, setDetectedRectangle] = (0, react_1.useState)(null);
76
+ const lastRectangleRef = (0, react_1.useRef)(null);
76
77
  (0, react_1.useEffect)(() => {
77
78
  if (!autoCapture) {
78
79
  setIsAutoCapturing(false);
@@ -87,17 +88,18 @@ exports.DocScanner = (0, react_1.forwardRef)(({ onCapture, overlayColor = DEFAUL
87
88
  }, [quality]);
88
89
  const handlePictureTaken = (0, react_1.useCallback)((event) => {
89
90
  setIsAutoCapturing(false);
90
- const normalizedRectangle = normalizeRectangle(event.rectangleCoordinates ?? null);
91
+ const normalizedRectangle = normalizeRectangle(event.rectangleCoordinates ?? null) ?? lastRectangleRef.current;
91
92
  const quad = normalizedRectangle ? (0, coordinate_1.rectangleToQuad)(normalizedRectangle) : null;
92
93
  const initialPath = event.initialImage ?? null;
93
94
  const croppedPath = event.croppedImage ?? null;
94
- const bestPath = croppedPath ?? initialPath;
95
- if (bestPath) {
95
+ const editablePath = initialPath ?? croppedPath;
96
+ if (editablePath) {
96
97
  onCapture?.({
97
- path: bestPath,
98
+ path: editablePath,
98
99
  initialPath,
99
100
  croppedPath,
100
101
  quad,
102
+ rectangle: normalizedRectangle,
101
103
  width: event.width ?? 0,
102
104
  height: event.height ?? 0,
103
105
  });
@@ -157,6 +159,9 @@ exports.DocScanner = (0, react_1.forwardRef)(({ onCapture, overlayColor = DEFAUL
157
159
  setIsAutoCapturing(false);
158
160
  }
159
161
  }
162
+ if (payload.rectangleCoordinates) {
163
+ lastRectangleRef.current = payload.rectangleCoordinates;
164
+ }
160
165
  const isGoodRectangle = payload.lastDetectionType === 0;
161
166
  setDetectedRectangle(isGoodRectangle && rectangleOnScreen ? payload : null);
162
167
  onRectangleDetect?.(payload);
@@ -170,7 +175,7 @@ exports.DocScanner = (0, react_1.forwardRef)(({ onCapture, overlayColor = DEFAUL
170
175
  }
171
176
  },
172
177
  }), [capture]);
173
- const overlayPolygon = detectedRectangle?.rectangleOnScreen ?? null;
178
+ const overlayPolygon = detectedRectangle?.rectangleOnScreen ?? detectedRectangle?.rectangleCoordinates ?? null;
174
179
  const overlayIsActive = autoCapture ? isAutoCapturing : (detectedRectangle?.stableCounter ?? 0) > 0;
175
180
  return (react_1.default.createElement(react_native_1.View, { style: styles.container },
176
181
  react_1.default.createElement(react_native_document_scanner_1.default, { ref: scannerRef, style: styles.scanner, detectionCountBeforeCapture: minStableFrames, overlayColor: overlayColor, enableTorch: enableTorch, quality: normalizedQuality, useBase64: useBase64, manualOnly: !autoCapture, detectionConfig: detectionConfig, onPictureTaken: handlePictureTaken, onError: handleError, onRectangleDetect: handleRectangleDetect }),
@@ -63,6 +63,26 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
63
63
  }
64
64
  react_native_1.Image.getSize(ensureFileUri(capturedDoc.path), (width, height) => setImageSize({ width, height }), () => setImageSize({ width: capturedDoc.width, height: capturedDoc.height }));
65
65
  }, [capturedDoc]);
66
+ (0, react_1.useEffect)(() => {
67
+ if (!capturedDoc || !imageSize || cropRectangle) {
68
+ return;
69
+ }
70
+ const baseWidth = capturedDoc.width > 0 ? capturedDoc.width : imageSize.width;
71
+ const baseHeight = capturedDoc.height > 0 ? capturedDoc.height : imageSize.height;
72
+ let initialRectangle = null;
73
+ if (capturedDoc.rectangle) {
74
+ initialRectangle = (0, coordinate_1.scaleRectangle)(capturedDoc.rectangle, baseWidth, baseHeight, imageSize.width, imageSize.height);
75
+ }
76
+ else if (capturedDoc.quad && capturedDoc.quad.length === 4) {
77
+ const quadRectangle = (0, coordinate_1.quadToRectangle)(capturedDoc.quad);
78
+ if (quadRectangle) {
79
+ initialRectangle = (0, coordinate_1.scaleRectangle)(quadRectangle, baseWidth, baseHeight, imageSize.width, imageSize.height);
80
+ }
81
+ }
82
+ if (initialRectangle) {
83
+ setCropRectangle(initialRectangle);
84
+ }
85
+ }, [capturedDoc, imageSize, cropRectangle]);
66
86
  const resetState = (0, react_1.useCallback)(() => {
67
87
  setScreen('scanner');
68
88
  setCapturedDoc(null);
@@ -73,6 +93,8 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
73
93
  const handleCapture = (0, react_1.useCallback)((document) => {
74
94
  const normalizedPath = stripFileUri(document.path);
75
95
  const nextQuad = document.quad && document.quad.length === 4 ? document.quad : null;
96
+ const quadRectangle = nextQuad ? (0, coordinate_1.quadToRectangle)(nextQuad) : null;
97
+ const nextRectangle = document.rectangle ?? quadRectangle ?? null;
76
98
  const normalizedInitial = document.initialPath != null ? stripFileUri(document.initialPath) : normalizedPath;
77
99
  const normalizedCropped = document.croppedPath != null ? stripFileUri(document.croppedPath) : null;
78
100
  setCapturedDoc({
@@ -81,8 +103,9 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
81
103
  initialPath: normalizedInitial,
82
104
  croppedPath: normalizedCropped,
83
105
  quad: nextQuad,
106
+ rectangle: nextRectangle,
84
107
  });
85
- setCropRectangle(nextQuad ? (0, coordinate_1.quadToRectangle)(nextQuad) : null);
108
+ setCropRectangle(null);
86
109
  setScreen('crop');
87
110
  }, []);
88
111
  const handleCropChange = (0, react_1.useCallback)((rectangle) => {
@@ -104,13 +127,19 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
104
127
  if (!cropManager?.crop) {
105
128
  throw new Error('CustomCropManager.crop is not available');
106
129
  }
107
- const fallbackRectangle = capturedDoc.quad && capturedDoc.quad.length === 4
108
- ? (0, coordinate_1.quadToRectangle)(capturedDoc.quad)
130
+ const baseWidth = capturedDoc.width > 0 ? capturedDoc.width : size.width;
131
+ const baseHeight = capturedDoc.height > 0 ? capturedDoc.height : size.height;
132
+ const rectangleFromDetection = capturedDoc.rectangle
133
+ ? (0, coordinate_1.scaleRectangle)(capturedDoc.rectangle, baseWidth, baseHeight, size.width, size.height)
109
134
  : null;
110
- const scaledFallback = fallbackRectangle
111
- ? (0, coordinate_1.scaleRectangle)(fallbackRectangle, capturedDoc.width, capturedDoc.height, size.width, size.height)
112
- : null;
113
- const rectangle = cropRectangle ?? scaledFallback;
135
+ let fallbackRectangle = rectangleFromDetection;
136
+ if (!fallbackRectangle && capturedDoc.quad && capturedDoc.quad.length === 4) {
137
+ const quadRectangle = (0, coordinate_1.quadToRectangle)(capturedDoc.quad);
138
+ if (quadRectangle) {
139
+ fallbackRectangle = (0, coordinate_1.scaleRectangle)(quadRectangle, baseWidth, baseHeight, size.width, size.height);
140
+ }
141
+ }
142
+ const rectangle = cropRectangle ?? fallbackRectangle;
114
143
  const base64 = await new Promise((resolve, reject) => {
115
144
  cropManager.crop({
116
145
  topLeft: rectangle?.topLeft ?? { x: 0, y: 0 },
package/dist/types.d.ts CHANGED
@@ -14,6 +14,7 @@ export type CapturedDocument = {
14
14
  initialPath?: string | null;
15
15
  croppedPath?: string | null;
16
16
  quad: Point[] | null;
17
+ rectangle?: Rectangle | null;
17
18
  width: number;
18
19
  height: number;
19
20
  };
@@ -44,22 +44,7 @@ try {
44
44
  catch (error) {
45
45
  SvgModule = null;
46
46
  }
47
- const SCAN_DURATION_MS = 2200;
48
47
  const GRID_STEPS = [1 / 3, 2 / 3];
49
- const calculateMetrics = (polygon) => {
50
- const minX = Math.min(polygon.topLeft.x, polygon.bottomLeft.x, polygon.topRight.x, polygon.bottomRight.x);
51
- const maxX = Math.max(polygon.topLeft.x, polygon.bottomLeft.x, polygon.topRight.x, polygon.bottomRight.x);
52
- const minY = Math.min(polygon.topLeft.y, polygon.topRight.y, polygon.bottomLeft.y, polygon.bottomRight.y);
53
- const maxY = Math.max(polygon.topLeft.y, polygon.topRight.y, polygon.bottomLeft.y, polygon.bottomRight.y);
54
- return {
55
- minX,
56
- maxX,
57
- minY,
58
- maxY,
59
- width: maxX - minX,
60
- height: maxY - minY,
61
- };
62
- };
63
48
  const createPointsString = (polygon) => [
64
49
  `${polygon.topLeft.x},${polygon.topLeft.y}`,
65
50
  `${polygon.topRight.x},${polygon.topRight.y}`,
@@ -80,116 +65,51 @@ const createGridLines = (polygon) => GRID_STEPS.flatMap((step) => {
80
65
  { x1: verticalStart.x, y1: verticalStart.y, x2: verticalEnd.x, y2: verticalEnd.y },
81
66
  ];
82
67
  });
83
- const ScannerOverlay = ({ active, color = '#0b7ef4', lineWidth = react_native_1.StyleSheet.hairlineWidth, polygon, }) => {
84
- const scanProgress = (0, react_1.useRef)(new react_native_1.Animated.Value(0)).current;
85
- const fallbackBase = (0, react_1.useRef)(new react_native_1.Animated.Value(0)).current;
86
- const [scanY, setScanY] = (0, react_1.useState)(null);
87
- const metrics = (0, react_1.useMemo)(() => (polygon ? calculateMetrics(polygon) : null), [polygon]);
88
- const scanBarHeight = (0, react_1.useMemo)(() => {
89
- if (!metrics)
90
- return 0;
91
- return Math.max(metrics.height * 0.2, 16);
92
- }, [metrics]);
93
- const travelDistance = (0, react_1.useMemo)(() => {
94
- if (!metrics) {
95
- return 0;
96
- }
97
- return Math.max(metrics.height - scanBarHeight, 0);
98
- }, [metrics, scanBarHeight]);
99
- (0, react_1.useEffect)(() => {
100
- scanProgress.stopAnimation();
101
- scanProgress.setValue(0);
102
- setScanY(null);
103
- if (!active || !metrics || travelDistance <= 0) {
104
- return undefined;
105
- }
106
- const loop = react_native_1.Animated.loop(react_native_1.Animated.sequence([
107
- react_native_1.Animated.timing(scanProgress, {
108
- toValue: 1,
109
- duration: SCAN_DURATION_MS,
110
- easing: react_native_1.Easing.inOut(react_native_1.Easing.quad),
111
- useNativeDriver: false,
112
- }),
113
- react_native_1.Animated.timing(scanProgress, {
114
- toValue: 0,
115
- duration: SCAN_DURATION_MS,
116
- easing: react_native_1.Easing.inOut(react_native_1.Easing.quad),
117
- useNativeDriver: false,
118
- }),
119
- ]));
120
- loop.start();
121
- return () => {
122
- loop.stop();
123
- scanProgress.stopAnimation();
124
- };
125
- }, [active, metrics, scanProgress, travelDistance]);
126
- (0, react_1.useEffect)(() => {
127
- if (!metrics || travelDistance <= 0) {
128
- setScanY(null);
129
- return undefined;
130
- }
131
- const listenerId = scanProgress.addListener(({ value }) => {
132
- const nextValue = metrics.minY + travelDistance * value;
133
- if (Number.isFinite(nextValue)) {
134
- setScanY(nextValue);
135
- }
136
- });
137
- return () => {
138
- scanProgress.removeListener(listenerId);
139
- };
140
- }, [metrics, scanProgress, travelDistance]);
141
- if (!polygon || !metrics || metrics.width <= 0 || metrics.height <= 0) {
68
+ const getBounds = (polygon) => {
69
+ const minX = Math.min(polygon.topLeft.x, polygon.bottomLeft.x, polygon.topRight.x, polygon.bottomRight.x);
70
+ const maxX = Math.max(polygon.topLeft.x, polygon.bottomLeft.x, polygon.topRight.x, polygon.bottomRight.x);
71
+ const minY = Math.min(polygon.topLeft.y, polygon.topRight.y, polygon.bottomLeft.y, polygon.bottomRight.y);
72
+ const maxY = Math.max(polygon.topLeft.y, polygon.topRight.y, polygon.bottomLeft.y, polygon.bottomRight.y);
73
+ return {
74
+ minX,
75
+ minY,
76
+ width: maxX - minX,
77
+ height: maxY - minY,
78
+ };
79
+ };
80
+ const ScannerOverlay = ({ active: _active, // kept for compatibility; no animation currently
81
+ color = '#0b7ef4', lineWidth = react_native_1.StyleSheet.hairlineWidth, polygon, }) => {
82
+ const points = (0, react_1.useMemo)(() => (polygon ? createPointsString(polygon) : null), [polygon]);
83
+ const gridLines = (0, react_1.useMemo)(() => (polygon ? createGridLines(polygon) : []), [polygon]);
84
+ const bounds = (0, react_1.useMemo)(() => (polygon ? getBounds(polygon) : null), [polygon]);
85
+ if (!polygon || !points || !bounds) {
142
86
  return null;
143
87
  }
144
88
  if (SvgModule) {
145
- const { default: Svg, Polygon, Line, Defs, LinearGradient, Stop, Rect } = SvgModule;
146
- const gridLines = createGridLines(polygon);
147
- const points = createPointsString(polygon);
148
- const scanRectY = scanY ?? metrics.minY;
89
+ const { default: Svg, Polygon, Line } = SvgModule;
149
90
  return (react_1.default.createElement(react_native_1.View, { pointerEvents: "none", style: react_native_1.StyleSheet.absoluteFill },
150
91
  react_1.default.createElement(Svg, { style: react_native_1.StyleSheet.absoluteFill },
151
92
  react_1.default.createElement(Polygon, { points: points, fill: color, opacity: 0.15 }),
152
93
  gridLines.map((line, index) => (react_1.default.createElement(Line, { key: `grid-${index}`, x1: line.x1, y1: line.y1, x2: line.x2, y2: line.y2, stroke: color, strokeWidth: lineWidth, opacity: 0.5 }))),
153
- react_1.default.createElement(Polygon, { points: points, stroke: color, strokeWidth: lineWidth, fill: "none" }),
154
- react_1.default.createElement(Defs, null,
155
- react_1.default.createElement(LinearGradient, { id: "scanGradient", x1: "0", y1: "0", x2: "0", y2: "1" },
156
- react_1.default.createElement(Stop, { offset: "0%", stopColor: "rgba(255,255,255,0)" }),
157
- react_1.default.createElement(Stop, { offset: "50%", stopColor: color, stopOpacity: 0.8 }),
158
- react_1.default.createElement(Stop, { offset: "100%", stopColor: "rgba(255,255,255,0)" }))),
159
- active && travelDistance > 0 && Number.isFinite(scanRectY) && (react_1.default.createElement(Rect, { x: metrics.minX, width: metrics.width, height: scanBarHeight, fill: "url(#scanGradient)", y: scanRectY })))));
94
+ react_1.default.createElement(Polygon, { points: points, stroke: color, strokeWidth: lineWidth, fill: "none" }))));
160
95
  }
161
- const relativeTranslate = metrics && travelDistance > 0
162
- ? react_native_1.Animated.multiply(scanProgress, travelDistance)
163
- : fallbackBase;
164
96
  return (react_1.default.createElement(react_native_1.View, { pointerEvents: "none", style: react_native_1.StyleSheet.absoluteFill },
165
97
  react_1.default.createElement(react_native_1.View, { style: [
166
98
  styles.fallbackBox,
167
99
  {
168
- left: metrics.minX,
169
- top: metrics.minY,
170
- width: metrics.width,
171
- height: metrics.height,
100
+ left: bounds.minX,
101
+ top: bounds.minY,
102
+ width: bounds.width,
103
+ height: bounds.height,
172
104
  borderColor: color,
173
105
  borderWidth: lineWidth,
174
106
  },
175
- ] }, active && travelDistance > 0 && (react_1.default.createElement(react_native_1.Animated.View, { style: [
176
- styles.fallbackScanBar,
177
- {
178
- backgroundColor: color,
179
- height: scanBarHeight,
180
- transform: [{ translateY: relativeTranslate }],
181
- },
182
- ] })))));
107
+ ] })));
183
108
  };
184
109
  exports.ScannerOverlay = ScannerOverlay;
185
110
  const styles = react_native_1.StyleSheet.create({
186
111
  fallbackBox: {
187
112
  position: 'absolute',
188
113
  backgroundColor: 'rgba(11, 126, 244, 0.1)',
189
- overflow: 'hidden',
190
- },
191
- fallbackScanBar: {
192
- width: '100%',
193
- opacity: 0.4,
194
114
  },
195
115
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-rectangle-doc-scanner",
3
- "version": "3.27.0",
3
+ "version": "3.29.0",
4
4
  "description": "Native-backed document scanner for React Native with customizable overlays.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -70,26 +70,33 @@ export const CropEditor: React.FC<CropEditorProps> = ({
70
70
 
71
71
  // Get initial rectangle from detected quad or use default
72
72
  const getInitialRectangle = useCallback((): CropperRectangle | undefined => {
73
- if (!document.quad || !imageSize) {
73
+ if (!imageSize) {
74
74
  return undefined;
75
75
  }
76
76
 
77
- const rect = quadToRectangle(document.quad);
78
- if (!rect) {
77
+ const baseWidth = document.width > 0 ? document.width : imageSize.width;
78
+ const baseHeight = document.height > 0 ? document.height : imageSize.height;
79
+
80
+ const sourceRectangle = document.rectangle
81
+ ? document.rectangle
82
+ : document.quad && document.quad.length === 4
83
+ ? quadToRectangle(document.quad)
84
+ : null;
85
+
86
+ if (!sourceRectangle) {
79
87
  return undefined;
80
88
  }
81
89
 
82
- // Scale from original detection coordinates to image coordinates
83
90
  const scaled = scaleRectangle(
84
- rect,
85
- document.width,
86
- document.height,
91
+ sourceRectangle,
92
+ baseWidth,
93
+ baseHeight,
87
94
  imageSize.width,
88
95
  imageSize.height
89
96
  );
90
97
 
91
98
  return scaled as CropperRectangle;
92
- }, [document.quad, document.width, document.height, imageSize]);
99
+ }, [document.rectangle, document.quad, document.width, document.height, imageSize]);
93
100
 
94
101
  const handleImageLoad = useCallback((event: any) => {
95
102
  // This is just for debugging - actual size is loaded via Image.getSize in useEffect
@@ -33,6 +33,7 @@ export type DocScannerCapture = {
33
33
  initialPath: string | null;
34
34
  croppedPath: string | null;
35
35
  quad: Point[] | null;
36
+ rectangle: Rectangle | null;
36
37
  width: number;
37
38
  height: number;
38
39
  };
@@ -127,6 +128,7 @@ export const DocScanner = forwardRef<DocScannerHandle, Props>(
127
128
  } | null>(null);
128
129
  const [isAutoCapturing, setIsAutoCapturing] = useState(false);
129
130
  const [detectedRectangle, setDetectedRectangle] = useState<RectangleDetectEvent | null>(null);
131
+ const lastRectangleRef = useRef<Rectangle | null>(null);
130
132
 
131
133
  useEffect(() => {
132
134
  if (!autoCapture) {
@@ -146,19 +148,21 @@ export const DocScanner = forwardRef<DocScannerHandle, Props>(
146
148
  (event: PictureEvent) => {
147
149
  setIsAutoCapturing(false);
148
150
 
149
- const normalizedRectangle = normalizeRectangle(event.rectangleCoordinates ?? null);
151
+ const normalizedRectangle =
152
+ normalizeRectangle(event.rectangleCoordinates ?? null) ?? lastRectangleRef.current;
150
153
  const quad = normalizedRectangle ? rectangleToQuad(normalizedRectangle) : null;
151
154
 
152
155
  const initialPath = event.initialImage ?? null;
153
156
  const croppedPath = event.croppedImage ?? null;
154
- const bestPath = croppedPath ?? initialPath;
157
+ const editablePath = initialPath ?? croppedPath;
155
158
 
156
- if (bestPath) {
159
+ if (editablePath) {
157
160
  onCapture?.({
158
- path: bestPath,
161
+ path: editablePath,
159
162
  initialPath,
160
163
  croppedPath,
161
164
  quad,
165
+ rectangle: normalizedRectangle,
162
166
  width: event.width ?? 0,
163
167
  height: event.height ?? 0,
164
168
  });
@@ -224,13 +228,17 @@ export const DocScanner = forwardRef<DocScannerHandle, Props>(
224
228
  };
225
229
 
226
230
  if (autoCapture) {
227
- if (payload.stableCounter >= Math.max(minStableFrames - 1, 0)) {
231
+ if (payload.stableCounter >= Math.max(minStableFrames - 1, 0)) {
228
232
  setIsAutoCapturing(true);
229
233
  } else if (payload.stableCounter === 0) {
230
234
  setIsAutoCapturing(false);
231
235
  }
232
236
  }
233
237
 
238
+ if (payload.rectangleCoordinates) {
239
+ lastRectangleRef.current = payload.rectangleCoordinates;
240
+ }
241
+
234
242
  const isGoodRectangle = payload.lastDetectionType === 0;
235
243
  setDetectedRectangle(isGoodRectangle && rectangleOnScreen ? payload : null);
236
244
  onRectangleDetect?.(payload);
@@ -252,7 +260,7 @@ export const DocScanner = forwardRef<DocScannerHandle, Props>(
252
260
  [capture],
253
261
  );
254
262
 
255
- const overlayPolygon = detectedRectangle?.rectangleOnScreen ?? null;
263
+ const overlayPolygon = detectedRectangle?.rectangleOnScreen ?? detectedRectangle?.rectangleCoordinates ?? null;
256
264
  const overlayIsActive = autoCapture ? isAutoCapturing : (detectedRectangle?.stableCounter ?? 0) > 0;
257
265
 
258
266
  return (
@@ -116,6 +116,42 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
116
116
  );
117
117
  }, [capturedDoc]);
118
118
 
119
+ useEffect(() => {
120
+ if (!capturedDoc || !imageSize || cropRectangle) {
121
+ return;
122
+ }
123
+
124
+ const baseWidth = capturedDoc.width > 0 ? capturedDoc.width : imageSize.width;
125
+ const baseHeight = capturedDoc.height > 0 ? capturedDoc.height : imageSize.height;
126
+
127
+ let initialRectangle: Rectangle | null = null;
128
+
129
+ if (capturedDoc.rectangle) {
130
+ initialRectangle = scaleRectangle(
131
+ capturedDoc.rectangle,
132
+ baseWidth,
133
+ baseHeight,
134
+ imageSize.width,
135
+ imageSize.height,
136
+ );
137
+ } else if (capturedDoc.quad && capturedDoc.quad.length === 4) {
138
+ const quadRectangle = quadToRectangle(capturedDoc.quad as Quad);
139
+ if (quadRectangle) {
140
+ initialRectangle = scaleRectangle(
141
+ quadRectangle,
142
+ baseWidth,
143
+ baseHeight,
144
+ imageSize.width,
145
+ imageSize.height,
146
+ );
147
+ }
148
+ }
149
+
150
+ if (initialRectangle) {
151
+ setCropRectangle(initialRectangle);
152
+ }
153
+ }, [capturedDoc, imageSize, cropRectangle]);
154
+
119
155
  const resetState = useCallback(() => {
120
156
  setScreen('scanner');
121
157
  setCapturedDoc(null);
@@ -128,6 +164,8 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
128
164
  (document: CapturedDocument) => {
129
165
  const normalizedPath = stripFileUri(document.path);
130
166
  const nextQuad = document.quad && document.quad.length === 4 ? (document.quad as Quad) : null;
167
+ const quadRectangle = nextQuad ? quadToRectangle(nextQuad) : null;
168
+ const nextRectangle = document.rectangle ?? quadRectangle ?? null;
131
169
  const normalizedInitial =
132
170
  document.initialPath != null ? stripFileUri(document.initialPath) : normalizedPath;
133
171
  const normalizedCropped =
@@ -139,8 +177,9 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
139
177
  initialPath: normalizedInitial,
140
178
  croppedPath: normalizedCropped,
141
179
  quad: nextQuad,
180
+ rectangle: nextRectangle,
142
181
  });
143
- setCropRectangle(nextQuad ? quadToRectangle(nextQuad) : null);
182
+ setCropRectangle(null);
144
183
  setScreen('crop');
145
184
  },
146
185
  [],
@@ -173,22 +212,35 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
173
212
  throw new Error('CustomCropManager.crop is not available');
174
213
  }
175
214
 
176
- const fallbackRectangle =
177
- capturedDoc.quad && capturedDoc.quad.length === 4
178
- ? quadToRectangle(capturedDoc.quad as Quad)
179
- : null;
215
+ const baseWidth = capturedDoc.width > 0 ? capturedDoc.width : size.width;
216
+ const baseHeight = capturedDoc.height > 0 ? capturedDoc.height : size.height;
180
217
 
181
- const scaledFallback = fallbackRectangle
218
+ const rectangleFromDetection = capturedDoc.rectangle
182
219
  ? scaleRectangle(
183
- fallbackRectangle,
184
- capturedDoc.width,
185
- capturedDoc.height,
220
+ capturedDoc.rectangle,
221
+ baseWidth,
222
+ baseHeight,
186
223
  size.width,
187
224
  size.height,
188
225
  )
189
226
  : null;
190
227
 
191
- const rectangle = cropRectangle ?? scaledFallback;
228
+ let fallbackRectangle: Rectangle | null = rectangleFromDetection;
229
+
230
+ if (!fallbackRectangle && capturedDoc.quad && capturedDoc.quad.length === 4) {
231
+ const quadRectangle = quadToRectangle(capturedDoc.quad as Quad);
232
+ if (quadRectangle) {
233
+ fallbackRectangle = scaleRectangle(
234
+ quadRectangle,
235
+ baseWidth,
236
+ baseHeight,
237
+ size.width,
238
+ size.height,
239
+ );
240
+ }
241
+ }
242
+
243
+ const rectangle = cropRectangle ?? fallbackRectangle;
192
244
 
193
245
  const base64 = await new Promise<string>((resolve, reject) => {
194
246
  cropManager.crop(
package/src/types.ts CHANGED
@@ -14,6 +14,7 @@ export type CapturedDocument = {
14
14
  initialPath?: string | null;
15
15
  croppedPath?: string | null;
16
16
  quad: Point[] | null;
17
+ rectangle?: Rectangle | null;
17
18
  width: number;
18
19
  height: number;
19
20
  };
@@ -1,5 +1,5 @@
1
- import React, { useEffect, useMemo, useRef, useState } from 'react';
2
- import { Animated, Easing, StyleSheet, View } from 'react-native';
1
+ import React, { useMemo } from 'react';
2
+ import { StyleSheet, View } from 'react-native';
3
3
  import type { Rectangle } from '../types';
4
4
 
5
5
  let SvgModule: typeof import('react-native-svg') | null = null;
@@ -11,19 +11,35 @@ try {
11
11
  SvgModule = null;
12
12
  }
13
13
 
14
- const SCAN_DURATION_MS = 2200;
15
14
  const GRID_STEPS = [1 / 3, 2 / 3];
16
15
 
17
- type PolygonMetrics = {
18
- minX: number;
19
- maxX: number;
20
- minY: number;
21
- maxY: number;
22
- width: number;
23
- height: number;
24
- };
16
+ const createPointsString = (polygon: Rectangle): string =>
17
+ [
18
+ `${polygon.topLeft.x},${polygon.topLeft.y}`,
19
+ `${polygon.topRight.x},${polygon.topRight.y}`,
20
+ `${polygon.bottomRight.x},${polygon.bottomRight.y}`,
21
+ `${polygon.bottomLeft.x},${polygon.bottomLeft.y}`,
22
+ ].join(' ');
23
+
24
+ const interpolatePoint = (a: { x: number; y: number }, b: { x: number; y: number }, t: number) => ({
25
+ x: a.x + (b.x - a.x) * t,
26
+ y: a.y + (b.y - a.y) * t,
27
+ });
28
+
29
+ const createGridLines = (polygon: Rectangle) =>
30
+ GRID_STEPS.flatMap((step) => {
31
+ const horizontalStart = interpolatePoint(polygon.topLeft, polygon.bottomLeft, step);
32
+ const horizontalEnd = interpolatePoint(polygon.topRight, polygon.bottomRight, step);
33
+ const verticalStart = interpolatePoint(polygon.topLeft, polygon.topRight, step);
34
+ const verticalEnd = interpolatePoint(polygon.bottomLeft, polygon.bottomRight, step);
35
+
36
+ return [
37
+ { x1: horizontalStart.x, y1: horizontalStart.y, x2: horizontalEnd.x, y2: horizontalEnd.y },
38
+ { x1: verticalStart.x, y1: verticalStart.y, x2: verticalEnd.x, y2: verticalEnd.y },
39
+ ];
40
+ });
25
41
 
26
- const calculateMetrics = (polygon: Rectangle): PolygonMetrics => {
42
+ const getBounds = (polygon: Rectangle) => {
27
43
  const minX = Math.min(
28
44
  polygon.topLeft.x,
29
45
  polygon.bottomLeft.x,
@@ -51,40 +67,12 @@ const calculateMetrics = (polygon: Rectangle): PolygonMetrics => {
51
67
 
52
68
  return {
53
69
  minX,
54
- maxX,
55
70
  minY,
56
- maxY,
57
71
  width: maxX - minX,
58
72
  height: maxY - minY,
59
73
  };
60
74
  };
61
75
 
62
- const createPointsString = (polygon: Rectangle): string =>
63
- [
64
- `${polygon.topLeft.x},${polygon.topLeft.y}`,
65
- `${polygon.topRight.x},${polygon.topRight.y}`,
66
- `${polygon.bottomRight.x},${polygon.bottomRight.y}`,
67
- `${polygon.bottomLeft.x},${polygon.bottomLeft.y}`,
68
- ].join(' ');
69
-
70
- const interpolatePoint = (a: { x: number; y: number }, b: { x: number; y: number }, t: number) => ({
71
- x: a.x + (b.x - a.x) * t,
72
- y: a.y + (b.y - a.y) * t,
73
- });
74
-
75
- const createGridLines = (polygon: Rectangle) =>
76
- GRID_STEPS.flatMap((step) => {
77
- const horizontalStart = interpolatePoint(polygon.topLeft, polygon.bottomLeft, step);
78
- const horizontalEnd = interpolatePoint(polygon.topRight, polygon.bottomRight, step);
79
- const verticalStart = interpolatePoint(polygon.topLeft, polygon.topRight, step);
80
- const verticalEnd = interpolatePoint(polygon.bottomLeft, polygon.bottomRight, step);
81
-
82
- return [
83
- { x1: horizontalStart.x, y1: horizontalStart.y, x2: horizontalEnd.x, y2: horizontalEnd.y },
84
- { x1: verticalStart.x, y1: verticalStart.y, x2: verticalEnd.x, y2: verticalEnd.y },
85
- ];
86
- });
87
-
88
76
  export interface ScannerOverlayProps {
89
77
  active: boolean;
90
78
  color?: string;
@@ -93,89 +81,21 @@ export interface ScannerOverlayProps {
93
81
  }
94
82
 
95
83
  export const ScannerOverlay: React.FC<ScannerOverlayProps> = ({
96
- active,
84
+ active: _active, // kept for compatibility; no animation currently
97
85
  color = '#0b7ef4',
98
86
  lineWidth = StyleSheet.hairlineWidth,
99
87
  polygon,
100
88
  }) => {
101
- const scanProgress = useRef(new Animated.Value(0)).current;
102
- const fallbackBase = useRef(new Animated.Value(0)).current;
103
- const [scanY, setScanY] = useState<number | null>(null);
104
-
105
- const metrics = useMemo(() => (polygon ? calculateMetrics(polygon) : null), [polygon]);
106
-
107
- const scanBarHeight = useMemo(() => {
108
- if (!metrics) return 0;
109
- return Math.max(metrics.height * 0.2, 16);
110
- }, [metrics]);
111
-
112
- const travelDistance = useMemo(() => {
113
- if (!metrics) {
114
- return 0;
115
- }
116
- return Math.max(metrics.height - scanBarHeight, 0);
117
- }, [metrics, scanBarHeight]);
118
-
119
- useEffect(() => {
120
- scanProgress.stopAnimation();
121
- scanProgress.setValue(0);
122
- setScanY(null);
123
-
124
- if (!active || !metrics || travelDistance <= 0) {
125
- return undefined;
126
- }
127
-
128
- const loop = Animated.loop(
129
- Animated.sequence([
130
- Animated.timing(scanProgress, {
131
- toValue: 1,
132
- duration: SCAN_DURATION_MS,
133
- easing: Easing.inOut(Easing.quad),
134
- useNativeDriver: false,
135
- }),
136
- Animated.timing(scanProgress, {
137
- toValue: 0,
138
- duration: SCAN_DURATION_MS,
139
- easing: Easing.inOut(Easing.quad),
140
- useNativeDriver: false,
141
- }),
142
- ]),
143
- );
89
+ const points = useMemo(() => (polygon ? createPointsString(polygon) : null), [polygon]);
90
+ const gridLines = useMemo(() => (polygon ? createGridLines(polygon) : []), [polygon]);
91
+ const bounds = useMemo(() => (polygon ? getBounds(polygon) : null), [polygon]);
144
92
 
145
- loop.start();
146
- return () => {
147
- loop.stop();
148
- scanProgress.stopAnimation();
149
- };
150
- }, [active, metrics, scanProgress, travelDistance]);
151
-
152
- useEffect(() => {
153
- if (!metrics || travelDistance <= 0) {
154
- setScanY(null);
155
- return undefined;
156
- }
157
-
158
- const listenerId = scanProgress.addListener(({ value }) => {
159
- const nextValue = metrics.minY + travelDistance * value;
160
- if (Number.isFinite(nextValue)) {
161
- setScanY(nextValue);
162
- }
163
- });
164
-
165
- return () => {
166
- scanProgress.removeListener(listenerId);
167
- };
168
- }, [metrics, scanProgress, travelDistance]);
169
-
170
- if (!polygon || !metrics || metrics.width <= 0 || metrics.height <= 0) {
93
+ if (!polygon || !points || !bounds) {
171
94
  return null;
172
95
  }
173
96
 
174
97
  if (SvgModule) {
175
- const { default: Svg, Polygon, Line, Defs, LinearGradient, Stop, Rect } = SvgModule;
176
- const gridLines = createGridLines(polygon);
177
- const points = createPointsString(polygon);
178
- const scanRectY = scanY ?? metrics.minY;
98
+ const { default: Svg, Polygon, Line } = SvgModule;
179
99
 
180
100
  return (
181
101
  <View pointerEvents="none" style={StyleSheet.absoluteFill}>
@@ -194,60 +114,26 @@ export const ScannerOverlay: React.FC<ScannerOverlayProps> = ({
194
114
  />
195
115
  ))}
196
116
  <Polygon points={points} stroke={color} strokeWidth={lineWidth} fill="none" />
197
- <Defs>
198
- <LinearGradient id="scanGradient" x1="0" y1="0" x2="0" y2="1">
199
- <Stop offset="0%" stopColor="rgba(255,255,255,0)" />
200
- <Stop offset="50%" stopColor={color} stopOpacity={0.8} />
201
- <Stop offset="100%" stopColor="rgba(255,255,255,0)" />
202
- </LinearGradient>
203
- </Defs>
204
- {active && travelDistance > 0 && Number.isFinite(scanRectY) && (
205
- <Rect
206
- x={metrics.minX}
207
- width={metrics.width}
208
- height={scanBarHeight}
209
- fill="url(#scanGradient)"
210
- y={scanRectY}
211
- />
212
- )}
213
117
  </Svg>
214
118
  </View>
215
119
  );
216
120
  }
217
121
 
218
- const relativeTranslate =
219
- metrics && travelDistance > 0
220
- ? Animated.multiply(scanProgress, travelDistance)
221
- : fallbackBase;
222
-
223
122
  return (
224
123
  <View pointerEvents="none" style={StyleSheet.absoluteFill}>
225
124
  <View
226
125
  style={[
227
126
  styles.fallbackBox,
228
127
  {
229
- left: metrics.minX,
230
- top: metrics.minY,
231
- width: metrics.width,
232
- height: metrics.height,
128
+ left: bounds.minX,
129
+ top: bounds.minY,
130
+ width: bounds.width,
131
+ height: bounds.height,
233
132
  borderColor: color,
234
133
  borderWidth: lineWidth,
235
134
  },
236
135
  ]}
237
- >
238
- {active && travelDistance > 0 && (
239
- <Animated.View
240
- style={[
241
- styles.fallbackScanBar,
242
- {
243
- backgroundColor: color,
244
- height: scanBarHeight,
245
- transform: [{ translateY: relativeTranslate }],
246
- },
247
- ]}
248
- />
249
- )}
250
- </View>
136
+ />
251
137
  </View>
252
138
  );
253
139
  };
@@ -256,10 +142,5 @@ const styles = StyleSheet.create({
256
142
  fallbackBox: {
257
143
  position: 'absolute',
258
144
  backgroundColor: 'rgba(11, 126, 244, 0.1)',
259
- overflow: 'hidden',
260
- },
261
- fallbackScanBar: {
262
- width: '100%',
263
- opacity: 0.4,
264
145
  },
265
146
  });