react-native-rectangle-doc-scanner 3.26.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,23 +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
- centerX: minX + (maxX - minX) / 2,
62
- };
63
- };
64
48
  const createPointsString = (polygon) => [
65
49
  `${polygon.topLeft.x},${polygon.topLeft.y}`,
66
50
  `${polygon.topRight.x},${polygon.topRight.y}`,
@@ -81,100 +65,51 @@ const createGridLines = (polygon) => GRID_STEPS.flatMap((step) => {
81
65
  { x1: verticalStart.x, y1: verticalStart.y, x2: verticalEnd.x, y2: verticalEnd.y },
82
66
  ];
83
67
  });
84
- const ScannerOverlay = ({ active, color = '#0b7ef4', lineWidth = react_native_1.StyleSheet.hairlineWidth, polygon, }) => {
85
- const scanProgress = (0, react_1.useRef)(new react_native_1.Animated.Value(0)).current;
86
- const fallbackBase = (0, react_1.useRef)(new react_native_1.Animated.Value(0)).current;
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 scanTranslate = (0, react_1.useMemo)(() => {
94
- if (!metrics || scanBarHeight === 0) {
95
- return null;
96
- }
97
- return scanProgress.interpolate({
98
- inputRange: [0, 1],
99
- outputRange: [metrics.minY, Math.max(metrics.minY, metrics.maxY - scanBarHeight)],
100
- });
101
- }, [metrics, scanBarHeight, scanProgress]);
102
- (0, react_1.useEffect)(() => {
103
- if (!active || !metrics || metrics.height <= 1) {
104
- scanProgress.stopAnimation();
105
- scanProgress.setValue(0);
106
- return undefined;
107
- }
108
- const loop = react_native_1.Animated.loop(react_native_1.Animated.sequence([
109
- react_native_1.Animated.timing(scanProgress, {
110
- toValue: 1,
111
- duration: SCAN_DURATION_MS,
112
- easing: react_native_1.Easing.inOut(react_native_1.Easing.quad),
113
- useNativeDriver: false,
114
- }),
115
- react_native_1.Animated.timing(scanProgress, {
116
- toValue: 0,
117
- duration: SCAN_DURATION_MS,
118
- easing: react_native_1.Easing.inOut(react_native_1.Easing.quad),
119
- useNativeDriver: false,
120
- }),
121
- ]));
122
- loop.start();
123
- return () => {
124
- loop.stop();
125
- };
126
- }, [active, metrics, scanProgress]);
127
- 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) {
128
86
  return null;
129
87
  }
130
88
  if (SvgModule) {
131
- const { default: Svg, Polygon, Line, Defs, LinearGradient, Stop, Rect } = SvgModule;
132
- const AnimatedRect = react_native_1.Animated.createAnimatedComponent(Rect);
133
- const gridLines = createGridLines(polygon);
134
- const points = createPointsString(polygon);
89
+ const { default: Svg, Polygon, Line } = SvgModule;
135
90
  return (react_1.default.createElement(react_native_1.View, { pointerEvents: "none", style: react_native_1.StyleSheet.absoluteFill },
136
91
  react_1.default.createElement(Svg, { style: react_native_1.StyleSheet.absoluteFill },
137
92
  react_1.default.createElement(Polygon, { points: points, fill: color, opacity: 0.15 }),
138
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 }))),
139
- react_1.default.createElement(Polygon, { points: points, stroke: color, strokeWidth: lineWidth, fill: "none" }),
140
- react_1.default.createElement(Defs, null,
141
- react_1.default.createElement(LinearGradient, { id: "scanGradient", x1: "0", y1: "0", x2: "0", y2: "1" },
142
- react_1.default.createElement(Stop, { offset: "0%", stopColor: "rgba(255,255,255,0)" }),
143
- react_1.default.createElement(Stop, { offset: "50%", stopColor: color, stopOpacity: 0.8 }),
144
- react_1.default.createElement(Stop, { offset: "100%", stopColor: "rgba(255,255,255,0)" }))),
145
- active && scanTranslate && (react_1.default.createElement(AnimatedRect, { x: metrics.minX, width: metrics.width, height: scanBarHeight, fill: "url(#scanGradient)", y: scanTranslate })))));
94
+ react_1.default.createElement(Polygon, { points: points, stroke: color, strokeWidth: lineWidth, fill: "none" }))));
146
95
  }
147
- // Fallback rendering without react-native-svg
148
- const relativeTranslate = scanTranslate != null ? react_native_1.Animated.subtract(scanTranslate, metrics.minY) : fallbackBase;
149
96
  return (react_1.default.createElement(react_native_1.View, { pointerEvents: "none", style: react_native_1.StyleSheet.absoluteFill },
150
97
  react_1.default.createElement(react_native_1.View, { style: [
151
98
  styles.fallbackBox,
152
99
  {
153
- left: metrics.minX,
154
- top: metrics.minY,
155
- width: metrics.width,
156
- height: metrics.height,
100
+ left: bounds.minX,
101
+ top: bounds.minY,
102
+ width: bounds.width,
103
+ height: bounds.height,
157
104
  borderColor: color,
158
105
  borderWidth: lineWidth,
159
106
  },
160
- ] }, active && (react_1.default.createElement(react_native_1.Animated.View, { style: [
161
- styles.fallbackScanBar,
162
- {
163
- backgroundColor: color,
164
- height: scanBarHeight,
165
- transform: [{ translateY: relativeTranslate }],
166
- },
167
- ] })))));
107
+ ] })));
168
108
  };
169
109
  exports.ScannerOverlay = ScannerOverlay;
170
110
  const styles = react_native_1.StyleSheet.create({
171
111
  fallbackBox: {
172
112
  position: 'absolute',
173
113
  backgroundColor: 'rgba(11, 126, 244, 0.1)',
174
- overflow: 'hidden',
175
- },
176
- fallbackScanBar: {
177
- width: '100%',
178
- opacity: 0.4,
179
114
  },
180
115
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-rectangle-doc-scanner",
3
- "version": "3.26.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 } 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,20 +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
- centerX: number;
25
- };
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);
26
35
 
27
- const calculateMetrics = (polygon: Rectangle): PolygonMetrics => {
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
+ });
41
+
42
+ const getBounds = (polygon: Rectangle) => {
28
43
  const minX = Math.min(
29
44
  polygon.topLeft.x,
30
45
  polygon.bottomLeft.x,
@@ -52,41 +67,12 @@ const calculateMetrics = (polygon: Rectangle): PolygonMetrics => {
52
67
 
53
68
  return {
54
69
  minX,
55
- maxX,
56
70
  minY,
57
- maxY,
58
71
  width: maxX - minX,
59
72
  height: maxY - minY,
60
- centerX: minX + (maxX - minX) / 2,
61
73
  };
62
74
  };
63
75
 
64
- const createPointsString = (polygon: Rectangle): string =>
65
- [
66
- `${polygon.topLeft.x},${polygon.topLeft.y}`,
67
- `${polygon.topRight.x},${polygon.topRight.y}`,
68
- `${polygon.bottomRight.x},${polygon.bottomRight.y}`,
69
- `${polygon.bottomLeft.x},${polygon.bottomLeft.y}`,
70
- ].join(' ');
71
-
72
- const interpolatePoint = (a: { x: number; y: number }, b: { x: number; y: number }, t: number) => ({
73
- x: a.x + (b.x - a.x) * t,
74
- y: a.y + (b.y - a.y) * t,
75
- });
76
-
77
- const createGridLines = (polygon: Rectangle) =>
78
- GRID_STEPS.flatMap((step) => {
79
- const horizontalStart = interpolatePoint(polygon.topLeft, polygon.bottomLeft, step);
80
- const horizontalEnd = interpolatePoint(polygon.topRight, polygon.bottomRight, step);
81
- const verticalStart = interpolatePoint(polygon.topLeft, polygon.topRight, step);
82
- const verticalEnd = interpolatePoint(polygon.bottomLeft, polygon.bottomRight, step);
83
-
84
- return [
85
- { x1: horizontalStart.x, y1: horizontalStart.y, x2: horizontalEnd.x, y2: horizontalEnd.y },
86
- { x1: verticalStart.x, y1: verticalStart.y, x2: verticalEnd.x, y2: verticalEnd.y },
87
- ];
88
- });
89
-
90
76
  export interface ScannerOverlayProps {
91
77
  active: boolean;
92
78
  color?: string;
@@ -95,72 +81,21 @@ export interface ScannerOverlayProps {
95
81
  }
96
82
 
97
83
  export const ScannerOverlay: React.FC<ScannerOverlayProps> = ({
98
- active,
84
+ active: _active, // kept for compatibility; no animation currently
99
85
  color = '#0b7ef4',
100
86
  lineWidth = StyleSheet.hairlineWidth,
101
87
  polygon,
102
88
  }) => {
103
- const scanProgress = useRef(new Animated.Value(0)).current;
104
- const fallbackBase = useRef(new Animated.Value(0)).current;
105
-
106
- const metrics = useMemo(() => (polygon ? calculateMetrics(polygon) : null), [polygon]);
107
-
108
- const scanBarHeight = useMemo(() => {
109
- if (!metrics) return 0;
110
- return Math.max(metrics.height * 0.2, 16);
111
- }, [metrics]);
112
-
113
- const scanTranslate = useMemo(() => {
114
- if (!metrics || scanBarHeight === 0) {
115
- return null;
116
- }
117
-
118
- return scanProgress.interpolate({
119
- inputRange: [0, 1],
120
- outputRange: [metrics.minY, Math.max(metrics.minY, metrics.maxY - scanBarHeight)],
121
- });
122
- }, [metrics, scanBarHeight, scanProgress]);
123
-
124
- useEffect(() => {
125
- if (!active || !metrics || metrics.height <= 1) {
126
- scanProgress.stopAnimation();
127
- scanProgress.setValue(0);
128
- return undefined;
129
- }
130
-
131
- const loop = Animated.loop(
132
- Animated.sequence([
133
- Animated.timing(scanProgress, {
134
- toValue: 1,
135
- duration: SCAN_DURATION_MS,
136
- easing: Easing.inOut(Easing.quad),
137
- useNativeDriver: false,
138
- }),
139
- Animated.timing(scanProgress, {
140
- toValue: 0,
141
- duration: SCAN_DURATION_MS,
142
- easing: Easing.inOut(Easing.quad),
143
- useNativeDriver: false,
144
- }),
145
- ]),
146
- );
147
-
148
- loop.start();
149
- return () => {
150
- loop.stop();
151
- };
152
- }, [active, metrics, scanProgress]);
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]);
153
92
 
154
- if (!polygon || !metrics || metrics.width <= 0 || metrics.height <= 0) {
93
+ if (!polygon || !points || !bounds) {
155
94
  return null;
156
95
  }
157
96
 
158
97
  if (SvgModule) {
159
- const { default: Svg, Polygon, Line, Defs, LinearGradient, Stop, Rect } = SvgModule;
160
- const AnimatedRect = Animated.createAnimatedComponent(Rect);
161
-
162
- const gridLines = createGridLines(polygon);
163
- const points = createPointsString(polygon);
98
+ const { default: Svg, Polygon, Line } = SvgModule;
164
99
 
165
100
  return (
166
101
  <View pointerEvents="none" style={StyleSheet.absoluteFill}>
@@ -179,59 +114,26 @@ export const ScannerOverlay: React.FC<ScannerOverlayProps> = ({
179
114
  />
180
115
  ))}
181
116
  <Polygon points={points} stroke={color} strokeWidth={lineWidth} fill="none" />
182
- <Defs>
183
- <LinearGradient id="scanGradient" x1="0" y1="0" x2="0" y2="1">
184
- <Stop offset="0%" stopColor="rgba(255,255,255,0)" />
185
- <Stop offset="50%" stopColor={color} stopOpacity={0.8} />
186
- <Stop offset="100%" stopColor="rgba(255,255,255,0)" />
187
- </LinearGradient>
188
- </Defs>
189
- {active && scanTranslate && (
190
- <AnimatedRect
191
- x={metrics.minX}
192
- width={metrics.width}
193
- height={scanBarHeight}
194
- fill="url(#scanGradient)"
195
- y={scanTranslate}
196
- />
197
- )}
198
117
  </Svg>
199
118
  </View>
200
119
  );
201
120
  }
202
121
 
203
- // Fallback rendering without react-native-svg
204
- const relativeTranslate =
205
- scanTranslate != null ? Animated.subtract(scanTranslate, metrics.minY) : fallbackBase;
206
-
207
122
  return (
208
123
  <View pointerEvents="none" style={StyleSheet.absoluteFill}>
209
124
  <View
210
125
  style={[
211
126
  styles.fallbackBox,
212
127
  {
213
- left: metrics.minX,
214
- top: metrics.minY,
215
- width: metrics.width,
216
- height: metrics.height,
128
+ left: bounds.minX,
129
+ top: bounds.minY,
130
+ width: bounds.width,
131
+ height: bounds.height,
217
132
  borderColor: color,
218
133
  borderWidth: lineWidth,
219
134
  },
220
135
  ]}
221
- >
222
- {active && (
223
- <Animated.View
224
- style={[
225
- styles.fallbackScanBar,
226
- {
227
- backgroundColor: color,
228
- height: scanBarHeight,
229
- transform: [{ translateY: relativeTranslate }],
230
- },
231
- ]}
232
- />
233
- )}
234
- </View>
136
+ />
235
137
  </View>
236
138
  );
237
139
  };
@@ -240,10 +142,5 @@ const styles = StyleSheet.create({
240
142
  fallbackBox: {
241
143
  position: 'absolute',
242
144
  backgroundColor: 'rgba(11, 126, 244, 0.1)',
243
- overflow: 'hidden',
244
- },
245
- fallbackScanBar: {
246
- width: '100%',
247
- opacity: 0.4,
248
145
  },
249
146
  });