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.
- package/dist/CropEditor.js +11 -6
- package/dist/DocScanner.d.ts +3 -0
- package/dist/DocScanner.js +45 -17
- package/dist/FullDocScanner.js +201 -38
- package/dist/types.d.ts +1 -0
- package/dist/utils/overlay.js +25 -105
- package/package.json +1 -1
- package/src/CropEditor.tsx +15 -8
- package/src/DocScanner.tsx +52 -17
- package/src/FullDocScanner.tsx +288 -55
- package/src/types.ts +1 -0
- package/src/utils/overlay.tsx +39 -158
package/dist/utils/overlay.js
CHANGED
|
@@ -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
|
|
84
|
-
const
|
|
85
|
-
const
|
|
86
|
-
const
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
(0, react_1.
|
|
100
|
-
|
|
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
|
|
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:
|
|
169
|
-
top:
|
|
170
|
-
width:
|
|
171
|
-
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
|
-
] }
|
|
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
package/src/CropEditor.tsx
CHANGED
|
@@ -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 (!
|
|
73
|
+
if (!imageSize) {
|
|
74
74
|
return undefined;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
const
|
|
78
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
package/src/DocScanner.tsx
CHANGED
|
@@ -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 =
|
|
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
|
|
163
|
+
const editablePath = initialPath ?? croppedPath;
|
|
155
164
|
|
|
156
|
-
if (
|
|
165
|
+
if (editablePath) {
|
|
157
166
|
onCapture?.({
|
|
158
|
-
path:
|
|
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
|
-
|
|
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.
|
|
196
|
-
|
|
197
|
-
|
|
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 = {
|
|
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
|
-
}, [
|
|
233
|
+
}, []);
|
|
205
234
|
|
|
206
235
|
const handleManualCapture = useCallback(() => {
|
|
207
|
-
|
|
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
|
-
}, [
|
|
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
|
-
|
|
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 &&
|
|
315
|
+
{(showManualCaptureButton || !autoCapture) && (
|
|
316
|
+
<TouchableOpacity style={styles.button} onPress={handleManualCapture} />
|
|
317
|
+
)}
|
|
283
318
|
{children}
|
|
284
319
|
</View>
|
|
285
320
|
);
|