react-native-rectangle-doc-scanner 3.27.0 → 3.31.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.
@@ -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.31.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,8 +33,10 @@ 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;
39
+ origin: 'auto' | 'manual';
38
40
  };
39
41
 
40
42
  const isFiniteNumber = (value: unknown): value is number =>
@@ -92,6 +94,7 @@ interface Props {
92
94
  gridLineWidth?: number;
93
95
  detectionConfig?: DetectionConfig;
94
96
  onRectangleDetect?: (event: RectangleDetectEvent) => void;
97
+ showManualCaptureButton?: boolean;
95
98
  }
96
99
 
97
100
  export type DocScannerHandle = {
@@ -117,6 +120,7 @@ export const DocScanner = forwardRef<DocScannerHandle, Props>(
117
120
  gridLineWidth,
118
121
  detectionConfig,
119
122
  onRectangleDetect,
123
+ showManualCaptureButton = false,
120
124
  },
121
125
  ref,
122
126
  ) => {
@@ -127,6 +131,8 @@ export const DocScanner = forwardRef<DocScannerHandle, Props>(
127
131
  } | null>(null);
128
132
  const [isAutoCapturing, setIsAutoCapturing] = useState(false);
129
133
  const [detectedRectangle, setDetectedRectangle] = useState<RectangleDetectEvent | null>(null);
134
+ const lastRectangleRef = useRef<Rectangle | null>(null);
135
+ const captureOriginRef = useRef<'auto' | 'manual'>('auto');
130
136
 
131
137
  useEffect(() => {
132
138
  if (!autoCapture) {
@@ -146,21 +152,26 @@ export const DocScanner = forwardRef<DocScannerHandle, Props>(
146
152
  (event: PictureEvent) => {
147
153
  setIsAutoCapturing(false);
148
154
 
149
- const normalizedRectangle = normalizeRectangle(event.rectangleCoordinates ?? null);
155
+ const normalizedRectangle =
156
+ normalizeRectangle(event.rectangleCoordinates ?? null) ?? lastRectangleRef.current;
150
157
  const quad = normalizedRectangle ? rectangleToQuad(normalizedRectangle) : null;
158
+ const origin = captureOriginRef.current;
159
+ captureOriginRef.current = 'auto';
151
160
 
152
161
  const initialPath = event.initialImage ?? null;
153
162
  const croppedPath = event.croppedImage ?? null;
154
- const bestPath = croppedPath ?? initialPath;
163
+ const editablePath = initialPath ?? croppedPath;
155
164
 
156
- if (bestPath) {
165
+ if (editablePath) {
157
166
  onCapture?.({
158
- path: bestPath,
167
+ path: editablePath,
159
168
  initialPath,
160
169
  croppedPath,
161
170
  quad,
171
+ rectangle: normalizedRectangle,
162
172
  width: event.width ?? 0,
163
173
  height: event.height ?? 0,
174
+ origin,
164
175
  });
165
176
  }
166
177
 
@@ -182,35 +193,52 @@ export const DocScanner = forwardRef<DocScannerHandle, Props>(
182
193
  }, []);
183
194
 
184
195
  const capture = useCallback((): Promise<PictureEvent> => {
196
+ captureOriginRef.current = 'manual';
185
197
  const instance = scannerRef.current;
186
198
  if (!instance || typeof instance.capture !== 'function') {
199
+ captureOriginRef.current = 'auto';
187
200
  return Promise.reject(new Error('DocumentScanner native instance is not ready'));
188
201
  }
189
202
  if (captureResolvers.current) {
203
+ captureOriginRef.current = 'auto';
190
204
  return Promise.reject(new Error('capture_in_progress'));
191
205
  }
192
206
 
193
- const result = instance.capture();
207
+ let result: any;
208
+ try {
209
+ result = instance.capture();
210
+ } catch (error) {
211
+ captureOriginRef.current = 'auto';
212
+ return Promise.reject(error);
213
+ }
194
214
  if (result && typeof result.then === 'function') {
195
- return result.then((payload: PictureEvent) => {
196
- handlePictureTaken(payload);
197
- return payload;
215
+ return result.catch((error: unknown) => {
216
+ captureOriginRef.current = 'auto';
217
+ throw error;
198
218
  });
199
219
  }
200
220
 
201
221
  return new Promise<PictureEvent>((resolve, reject) => {
202
- captureResolvers.current = { resolve, reject };
222
+ captureResolvers.current = {
223
+ resolve: (value) => {
224
+ captureOriginRef.current = 'auto';
225
+ resolve(value);
226
+ },
227
+ reject: (reason) => {
228
+ captureOriginRef.current = 'auto';
229
+ reject(reason);
230
+ },
231
+ };
203
232
  });
204
- }, [handlePictureTaken]);
233
+ }, []);
205
234
 
206
235
  const handleManualCapture = useCallback(() => {
207
- if (autoCapture) {
208
- return;
209
- }
236
+ captureOriginRef.current = 'manual';
210
237
  capture().catch((error) => {
238
+ captureOriginRef.current = 'auto';
211
239
  console.warn('[DocScanner] manual capture failed', error);
212
240
  });
213
- }, [autoCapture, capture]);
241
+ }, [capture]);
214
242
 
215
243
  const handleRectangleDetect = useCallback(
216
244
  (event: RectangleEventPayload) => {
@@ -224,13 +252,17 @@ export const DocScanner = forwardRef<DocScannerHandle, Props>(
224
252
  };
225
253
 
226
254
  if (autoCapture) {
227
- if (payload.stableCounter >= Math.max(minStableFrames - 1, 0)) {
255
+ if (payload.stableCounter >= Math.max(minStableFrames - 1, 0)) {
228
256
  setIsAutoCapturing(true);
229
257
  } else if (payload.stableCounter === 0) {
230
258
  setIsAutoCapturing(false);
231
259
  }
232
260
  }
233
261
 
262
+ if (payload.rectangleCoordinates) {
263
+ lastRectangleRef.current = payload.rectangleCoordinates;
264
+ }
265
+
234
266
  const isGoodRectangle = payload.lastDetectionType === 0;
235
267
  setDetectedRectangle(isGoodRectangle && rectangleOnScreen ? payload : null);
236
268
  onRectangleDetect?.(payload);
@@ -247,12 +279,13 @@ export const DocScanner = forwardRef<DocScannerHandle, Props>(
247
279
  captureResolvers.current.reject(new Error('reset'));
248
280
  captureResolvers.current = null;
249
281
  }
282
+ captureOriginRef.current = 'auto';
250
283
  },
251
284
  }),
252
285
  [capture],
253
286
  );
254
287
 
255
- const overlayPolygon = detectedRectangle?.rectangleOnScreen ?? null;
288
+ const overlayPolygon = detectedRectangle?.rectangleOnScreen ?? detectedRectangle?.rectangleCoordinates ?? null;
256
289
  const overlayIsActive = autoCapture ? isAutoCapturing : (detectedRectangle?.stableCounter ?? 0) > 0;
257
290
 
258
291
  return (
@@ -279,7 +312,9 @@ export const DocScanner = forwardRef<DocScannerHandle, Props>(
279
312
  polygon={overlayPolygon}
280
313
  />
281
314
  )}
282
- {!autoCapture && <TouchableOpacity style={styles.button} onPress={handleManualCapture} />}
315
+ {(showManualCaptureButton || !autoCapture) && (
316
+ <TouchableOpacity style={styles.button} onPress={handleManualCapture} />
317
+ )}
283
318
  {children}
284
319
  </View>
285
320
  );