react-native-rectangle-doc-scanner 3.23.0 → 3.26.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.
@@ -1,7 +1,6 @@
1
1
  import React from 'react';
2
2
  import type { Rectangle } from '../types';
3
3
  export interface ScannerOverlayProps {
4
- /** 자동 캡처 중임을 표시할 때 true로 설정합니다. */
5
4
  active: boolean;
6
5
  color?: string;
7
6
  lineWidth?: number;
@@ -36,59 +36,17 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.ScannerOverlay = void 0;
37
37
  const react_1 = __importStar(require("react"));
38
38
  const react_native_1 = require("react-native");
39
- const react_native_skia_1 = require("@shopify/react-native-skia");
40
- const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
41
- const withAlpha = (inputColor, alpha) => {
42
- const parsed = (0, react_native_1.processColor)(inputColor);
43
- const normalized = typeof parsed === 'number' ? parsed >>> 0 : null;
44
- if (normalized == null) {
45
- return inputColor;
46
- }
47
- const r = (normalized >> 16) & 0xff;
48
- const g = (normalized >> 8) & 0xff;
49
- const b = normalized & 0xff;
50
- const clampedAlpha = clamp(alpha, 0, 1);
51
- return `rgba(${r}, ${g}, ${b}, ${clampedAlpha})`;
52
- };
53
- const createPolygonPath = (polygon) => {
54
- if (!polygon) {
55
- return null;
56
- }
57
- const path = react_native_skia_1.Skia.Path.Make();
58
- path.moveTo(polygon.topLeft.x, polygon.topLeft.y);
59
- path.lineTo(polygon.topRight.x, polygon.topRight.y);
60
- path.lineTo(polygon.bottomRight.x, polygon.bottomRight.y);
61
- path.lineTo(polygon.bottomLeft.x, polygon.bottomLeft.y);
62
- path.close();
63
- return path;
64
- };
65
- const interpolate = (a, b, t) => ({
66
- x: a.x + (b.x - a.x) * t,
67
- y: a.y + (b.y - a.y) * t,
68
- });
69
- const createLinePath = (start, end) => {
70
- const path = react_native_skia_1.Skia.Path.Make();
71
- path.moveTo(start.x, start.y);
72
- path.lineTo(end.x, end.y);
73
- return path;
74
- };
75
- const createGridPaths = (polygon) => {
76
- if (!polygon) {
77
- return [];
78
- }
79
- const lines = [];
80
- const steps = [1 / 3, 2 / 3];
81
- steps.forEach((t) => {
82
- const horizontalStart = interpolate(polygon.topLeft, polygon.bottomLeft, t);
83
- const horizontalEnd = interpolate(polygon.topRight, polygon.bottomRight, t);
84
- lines.push(createLinePath(horizontalStart, horizontalEnd));
85
- const verticalStart = interpolate(polygon.topLeft, polygon.topRight, t);
86
- const verticalEnd = interpolate(polygon.bottomLeft, polygon.bottomRight, t);
87
- lines.push(createLinePath(verticalStart, verticalEnd));
88
- });
89
- return lines;
90
- };
91
- const getPolygonMetrics = (polygon) => {
39
+ let SvgModule = null;
40
+ try {
41
+ // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires
42
+ SvgModule = require('react-native-svg');
43
+ }
44
+ catch (error) {
45
+ SvgModule = null;
46
+ }
47
+ const SCAN_DURATION_MS = 2200;
48
+ const GRID_STEPS = [1 / 3, 2 / 3];
49
+ const calculateMetrics = (polygon) => {
92
50
  const minX = Math.min(polygon.topLeft.x, polygon.bottomLeft.x, polygon.topRight.x, polygon.bottomRight.x);
93
51
  const maxX = Math.max(polygon.topLeft.x, polygon.bottomLeft.x, polygon.topRight.x, polygon.bottomRight.x);
94
52
  const minY = Math.min(polygon.topLeft.y, polygon.topRight.y, polygon.bottomLeft.y, polygon.bottomRight.y);
@@ -103,66 +61,120 @@ const getPolygonMetrics = (polygon) => {
103
61
  centerX: minX + (maxX - minX) / 2,
104
62
  };
105
63
  };
106
- const SCAN_DURATION_MS = 2200;
64
+ const createPointsString = (polygon) => [
65
+ `${polygon.topLeft.x},${polygon.topLeft.y}`,
66
+ `${polygon.topRight.x},${polygon.topRight.y}`,
67
+ `${polygon.bottomRight.x},${polygon.bottomRight.y}`,
68
+ `${polygon.bottomLeft.x},${polygon.bottomLeft.y}`,
69
+ ].join(' ');
70
+ const interpolatePoint = (a, b, t) => ({
71
+ x: a.x + (b.x - a.x) * t,
72
+ y: a.y + (b.y - a.y) * t,
73
+ });
74
+ const createGridLines = (polygon) => GRID_STEPS.flatMap((step) => {
75
+ const horizontalStart = interpolatePoint(polygon.topLeft, polygon.bottomLeft, step);
76
+ const horizontalEnd = interpolatePoint(polygon.topRight, polygon.bottomRight, step);
77
+ const verticalStart = interpolatePoint(polygon.topLeft, polygon.topRight, step);
78
+ const verticalEnd = interpolatePoint(polygon.bottomLeft, polygon.bottomRight, step);
79
+ return [
80
+ { x1: horizontalStart.x, y1: horizontalStart.y, x2: horizontalEnd.x, y2: horizontalEnd.y },
81
+ { x1: verticalStart.x, y1: verticalStart.y, x2: verticalEnd.x, y2: verticalEnd.y },
82
+ ];
83
+ });
107
84
  const ScannerOverlay = ({ active, color = '#0b7ef4', lineWidth = react_native_1.StyleSheet.hairlineWidth, polygon, }) => {
108
- const path = (0, react_1.useMemo)(() => createPolygonPath(polygon ?? null), [polygon]);
109
- const gridPaths = (0, react_1.useMemo)(() => createGridPaths(polygon ?? null), [polygon]);
110
- const metrics = (0, react_1.useMemo)(() => (polygon ? getPolygonMetrics(polygon) : null), [polygon]);
111
- const gradientStart = (0, react_native_skia_1.useValue)((0, react_native_skia_1.vec)(0, 0));
112
- const gradientEnd = (0, react_native_skia_1.useValue)((0, react_native_skia_1.vec)(0, 0));
113
- const gradientColors = (0, react_native_skia_1.useValue)([
114
- react_native_skia_1.Skia.Color(withAlpha(color, 0)),
115
- react_native_skia_1.Skia.Color(withAlpha(color, 0.85)),
116
- react_native_skia_1.Skia.Color(withAlpha(color, 0)),
117
- ]);
118
- const gradientPositions = (0, react_native_skia_1.useValue)([0, 0.5, 1]);
119
- (0, react_1.useEffect)(() => {
120
- if (!metrics) {
121
- return;
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;
122
96
  }
123
- let frame = null;
124
- const transparentColor = react_native_skia_1.Skia.Color(withAlpha(color, 0));
125
- const highlightColor = react_native_skia_1.Skia.Color(withAlpha(color, 0.9));
126
- const bandSize = Math.max(metrics.height * 0.25, 20);
127
- const animate = () => {
128
- const now = Date.now() % SCAN_DURATION_MS;
129
- const progress = now / SCAN_DURATION_MS;
130
- const travel = metrics.height + bandSize * 2;
131
- const start = metrics.minY - bandSize + travel * progress;
132
- const end = start + bandSize;
133
- const clampedStart = clamp(start, metrics.minY, metrics.maxY);
134
- const clampedEnd = clamp(end, metrics.minY, metrics.maxY);
135
- gradientStart.current = (0, react_native_skia_1.vec)(metrics.centerX, clampedStart);
136
- gradientEnd.current = (0, react_native_skia_1.vec)(metrics.centerX, clampedEnd <= clampedStart ? clampedStart + 1 : clampedEnd);
137
- gradientColors.current = [transparentColor, highlightColor, transparentColor];
138
- frame = requestAnimationFrame(animate);
139
- };
140
- gradientStart.current = (0, react_native_skia_1.vec)(metrics.centerX, metrics.minY);
141
- gradientEnd.current = (0, react_native_skia_1.vec)(metrics.centerX, metrics.maxY);
142
- if (active) {
143
- animate();
144
- }
145
- else {
146
- gradientColors.current = [transparentColor, transparentColor, transparentColor];
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;
147
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();
148
123
  return () => {
149
- if (frame !== null) {
150
- cancelAnimationFrame(frame);
151
- }
124
+ loop.stop();
152
125
  };
153
- }, [active, color, gradientColors, gradientEnd, gradientStart, metrics]);
154
- if (!polygon || !path || !metrics) {
126
+ }, [active, metrics, scanProgress]);
127
+ if (!polygon || !metrics || metrics.width <= 0 || metrics.height <= 0) {
155
128
  return null;
156
129
  }
157
- const strokeColor = withAlpha(color, 0.9);
158
- const fillColor = withAlpha(color, 0.18);
159
- const gridColor = withAlpha(color, 0.35);
160
- return (react_1.default.createElement(react_native_1.View, { pointerEvents: "none", style: react_native_1.StyleSheet.absoluteFillObject },
161
- react_1.default.createElement(react_native_skia_1.Canvas, { style: react_native_1.StyleSheet.absoluteFillObject },
162
- react_1.default.createElement(react_native_skia_1.Path, { path: path, color: fillColor, style: "fill" }),
163
- gridPaths.map((gridPath, index) => (react_1.default.createElement(react_native_skia_1.Path, { key: `grid-${index}`, path: gridPath, color: gridColor, style: "stroke", strokeWidth: lineWidth }))),
164
- react_1.default.createElement(react_native_skia_1.Path, { path: path, color: strokeColor, style: "stroke", strokeWidth: lineWidth }),
165
- react_1.default.createElement(react_native_skia_1.Path, { path: path },
166
- react_1.default.createElement(react_native_skia_1.LinearGradient, { start: gradientStart, end: gradientEnd, colors: gradientColors, positions: gradientPositions })))));
130
+ 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);
135
+ return (react_1.default.createElement(react_native_1.View, { pointerEvents: "none", style: react_native_1.StyleSheet.absoluteFill },
136
+ react_1.default.createElement(Svg, { style: react_native_1.StyleSheet.absoluteFill },
137
+ react_1.default.createElement(Polygon, { points: points, fill: color, opacity: 0.15 }),
138
+ 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 })))));
146
+ }
147
+ // Fallback rendering without react-native-svg
148
+ const relativeTranslate = scanTranslate != null ? react_native_1.Animated.subtract(scanTranslate, metrics.minY) : fallbackBase;
149
+ return (react_1.default.createElement(react_native_1.View, { pointerEvents: "none", style: react_native_1.StyleSheet.absoluteFill },
150
+ react_1.default.createElement(react_native_1.View, { style: [
151
+ styles.fallbackBox,
152
+ {
153
+ left: metrics.minX,
154
+ top: metrics.minY,
155
+ width: metrics.width,
156
+ height: metrics.height,
157
+ borderColor: color,
158
+ borderWidth: lineWidth,
159
+ },
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
+ ] })))));
167
168
  };
168
169
  exports.ScannerOverlay = ScannerOverlay;
170
+ const styles = react_native_1.StyleSheet.create({
171
+ fallbackBox: {
172
+ position: 'absolute',
173
+ backgroundColor: 'rgba(11, 126, 244, 0.1)',
174
+ overflow: 'hidden',
175
+ },
176
+ fallbackScanBar: {
177
+ width: '100%',
178
+ opacity: 0.4,
179
+ },
180
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-rectangle-doc-scanner",
3
- "version": "3.23.0",
3
+ "version": "3.26.0",
4
4
  "description": "Native-backed document scanner for React Native with customizable overlays.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -30,10 +30,10 @@
30
30
  "*.json"
31
31
  ],
32
32
  "peerDependencies": {
33
- "@shopify/react-native-skia": "*",
34
33
  "react": "*",
35
34
  "react-native": "*",
36
- "react-native-perspective-image-cropper": "*"
35
+ "react-native-perspective-image-cropper": "*",
36
+ "react-native-svg": "*"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/react": "^18.2.41",
package/src/external.d.ts CHANGED
@@ -1,55 +1,3 @@
1
- declare module '@shopify/react-native-skia' {
2
- import type { ComponentType, ReactNode } from 'react';
3
- import type { ViewStyle } from 'react-native';
4
-
5
- export type SkPath = {
6
- moveTo: (x: number, y: number) => void;
7
- lineTo: (x: number, y: number) => void;
8
- close: () => void;
9
- };
10
-
11
- export const Skia: {
12
- Path: {
13
- Make: () => SkPath;
14
- };
15
- Color: (color: string | number) => number;
16
- };
17
-
18
- export type CanvasProps = {
19
- style?: ViewStyle;
20
- children?: ReactNode;
21
- };
22
-
23
- export const Canvas: ComponentType<CanvasProps>;
24
-
25
- export type PathProps = {
26
- path: SkPath;
27
- style?: 'stroke' | 'fill';
28
- strokeWidth?: number;
29
- color?: string;
30
- children?: ReactNode;
31
- };
32
-
33
- export const Path: ComponentType<PathProps>;
34
-
35
- export type SkiaValue<T> = {
36
- current: T;
37
- };
38
-
39
- export const useValue: <T>(initialValue: T) => SkiaValue<T>;
40
-
41
- export const vec: (x: number, y: number) => { x: number; y: number };
42
-
43
- export type LinearGradientProps = {
44
- start: SkiaValue<{ x: number; y: number }> | { x: number; y: number };
45
- end: SkiaValue<{ x: number; y: number }> | { x: number; y: number };
46
- colors: SkiaValue<number[]> | number[];
47
- positions?: SkiaValue<number[]> | number[];
48
- };
49
-
50
- export const LinearGradient: ComponentType<LinearGradientProps>;
51
- }
52
-
53
1
  declare module 'react-native-perspective-image-cropper' {
54
2
  import type { ComponentType } from 'react';
55
3
 
@@ -135,3 +83,65 @@ declare module 'react-native-document-scanner' {
135
83
  capture(): Promise<DocumentScannerResult>;
136
84
  }
137
85
  }
86
+
87
+ declare module 'react-native-svg' {
88
+ import type { ComponentType, ReactNode } from 'react';
89
+
90
+ export type SvgProps = {
91
+ children?: ReactNode;
92
+ style?: any;
93
+ width?: number | string;
94
+ height?: number | string;
95
+ viewBox?: string;
96
+ };
97
+
98
+ export type PolygonProps = {
99
+ points: string;
100
+ fill?: string;
101
+ stroke?: string;
102
+ strokeWidth?: number;
103
+ opacity?: number;
104
+ };
105
+
106
+ export type LineProps = {
107
+ x1: number;
108
+ y1: number;
109
+ x2: number;
110
+ y2: number;
111
+ stroke?: string;
112
+ strokeWidth?: number;
113
+ opacity?: number;
114
+ };
115
+
116
+ export type RectProps = {
117
+ x: number;
118
+ y: number;
119
+ width: number;
120
+ height: number;
121
+ fill?: string;
122
+ };
123
+
124
+ export type StopProps = {
125
+ offset: string;
126
+ stopColor: string;
127
+ stopOpacity?: number;
128
+ };
129
+
130
+ export type LinearGradientProps = {
131
+ id: string;
132
+ x1?: string;
133
+ y1?: string;
134
+ x2?: string;
135
+ y2?: string;
136
+ children?: ReactNode;
137
+ };
138
+
139
+ declare const Svg: ComponentType<SvgProps>;
140
+ export default Svg;
141
+ export const Polygon: ComponentType<PolygonProps>;
142
+ export const Line: ComponentType<LineProps>;
143
+ export const Rect: ComponentType<RectProps>;
144
+ export const Defs: ComponentType<{ children?: ReactNode }>;
145
+ export const LinearGradient: ComponentType<LinearGradientProps>;
146
+ export const Stop: ComponentType<StopProps>;
147
+ }
@@ -1,94 +1,54 @@
1
- import React, { useEffect, useMemo } from 'react';
2
- import { View, processColor, StyleSheet } from 'react-native';
3
- import {
4
- Canvas,
5
- LinearGradient,
6
- Path,
7
- Skia,
8
- SkPath,
9
- useValue,
10
- vec,
11
- } from '@shopify/react-native-skia';
12
- import type { Point, Rectangle } from '../types';
1
+ import React, { useEffect, useMemo, useRef } from 'react';
2
+ import { Animated, Easing, StyleSheet, View } from 'react-native';
3
+ import type { Rectangle } from '../types';
13
4
 
14
- export interface ScannerOverlayProps {
15
- /** 자동 캡처 중임을 표시할 때 true로 설정합니다. */
16
- active: boolean;
17
- color?: string;
18
- lineWidth?: number;
19
- polygon?: Rectangle | null;
20
- }
21
-
22
- const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
23
-
24
- const withAlpha = (inputColor: string, alpha: number): string => {
25
- const parsed = processColor(inputColor);
26
- const normalized = typeof parsed === 'number' ? parsed >>> 0 : null;
27
-
28
- if (normalized == null) {
29
- return inputColor;
30
- }
31
-
32
- const r = (normalized >> 16) & 0xff;
33
- const g = (normalized >> 8) & 0xff;
34
- const b = normalized & 0xff;
35
- const clampedAlpha = clamp(alpha, 0, 1);
36
-
37
- return `rgba(${r}, ${g}, ${b}, ${clampedAlpha})`;
38
- };
39
-
40
- const createPolygonPath = (polygon: Rectangle | null): SkPath | null => {
41
- if (!polygon) {
42
- return null;
43
- }
44
-
45
- const path = Skia.Path.Make();
46
- path.moveTo(polygon.topLeft.x, polygon.topLeft.y);
47
- path.lineTo(polygon.topRight.x, polygon.topRight.y);
48
- path.lineTo(polygon.bottomRight.x, polygon.bottomRight.y);
49
- path.lineTo(polygon.bottomLeft.x, polygon.bottomLeft.y);
50
- path.close();
51
- return path;
52
- };
53
-
54
- const interpolate = (a: Point, b: Point, t: number): Point => ({
55
- x: a.x + (b.x - a.x) * t,
56
- y: a.y + (b.y - a.y) * t,
57
- });
5
+ let SvgModule: typeof import('react-native-svg') | null = null;
58
6
 
59
- const createLinePath = (start: Point, end: Point): SkPath => {
60
- const path = Skia.Path.Make();
61
- path.moveTo(start.x, start.y);
62
- path.lineTo(end.x, end.y);
63
- return path;
64
- };
65
-
66
- const createGridPaths = (polygon: Rectangle | null): SkPath[] => {
67
- if (!polygon) {
68
- return [];
69
- }
70
-
71
- const lines: SkPath[] = [];
72
- const steps = [1 / 3, 2 / 3];
73
-
74
- steps.forEach((t) => {
75
- const horizontalStart = interpolate(polygon.topLeft, polygon.bottomLeft, t);
76
- const horizontalEnd = interpolate(polygon.topRight, polygon.bottomRight, t);
77
- lines.push(createLinePath(horizontalStart, horizontalEnd));
78
-
79
- const verticalStart = interpolate(polygon.topLeft, polygon.topRight, t);
80
- const verticalEnd = interpolate(polygon.bottomLeft, polygon.bottomRight, t);
81
- lines.push(createLinePath(verticalStart, verticalEnd));
82
- });
7
+ try {
8
+ // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires
9
+ SvgModule = require('react-native-svg');
10
+ } catch (error) {
11
+ SvgModule = null;
12
+ }
83
13
 
84
- return lines;
14
+ const SCAN_DURATION_MS = 2200;
15
+ const GRID_STEPS = [1 / 3, 2 / 3];
16
+
17
+ type PolygonMetrics = {
18
+ minX: number;
19
+ maxX: number;
20
+ minY: number;
21
+ maxY: number;
22
+ width: number;
23
+ height: number;
24
+ centerX: number;
85
25
  };
86
26
 
87
- const getPolygonMetrics = (polygon: Rectangle) => {
88
- const minX = Math.min(polygon.topLeft.x, polygon.bottomLeft.x, polygon.topRight.x, polygon.bottomRight.x);
89
- const maxX = Math.max(polygon.topLeft.x, polygon.bottomLeft.x, polygon.topRight.x, polygon.bottomRight.x);
90
- const minY = Math.min(polygon.topLeft.y, polygon.topRight.y, polygon.bottomLeft.y, polygon.bottomRight.y);
91
- const maxY = Math.max(polygon.topLeft.y, polygon.topRight.y, polygon.bottomLeft.y, polygon.bottomRight.y);
27
+ const calculateMetrics = (polygon: Rectangle): PolygonMetrics => {
28
+ const minX = Math.min(
29
+ polygon.topLeft.x,
30
+ polygon.bottomLeft.x,
31
+ polygon.topRight.x,
32
+ polygon.bottomRight.x,
33
+ );
34
+ const maxX = Math.max(
35
+ polygon.topLeft.x,
36
+ polygon.bottomLeft.x,
37
+ polygon.topRight.x,
38
+ polygon.bottomRight.x,
39
+ );
40
+ const minY = Math.min(
41
+ polygon.topLeft.y,
42
+ polygon.topRight.y,
43
+ polygon.bottomLeft.y,
44
+ polygon.bottomRight.y,
45
+ );
46
+ const maxY = Math.max(
47
+ polygon.topLeft.y,
48
+ polygon.topRight.y,
49
+ polygon.bottomLeft.y,
50
+ polygon.bottomRight.y,
51
+ );
92
52
 
93
53
  return {
94
54
  minX,
@@ -101,7 +61,38 @@ const getPolygonMetrics = (polygon: Rectangle) => {
101
61
  };
102
62
  };
103
63
 
104
- const SCAN_DURATION_MS = 2200;
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
+ export interface ScannerOverlayProps {
91
+ active: boolean;
92
+ color?: string;
93
+ lineWidth?: number;
94
+ polygon?: Rectangle | null;
95
+ }
105
96
 
106
97
  export const ScannerOverlay: React.FC<ScannerOverlayProps> = ({
107
98
  active,
@@ -109,90 +100,150 @@ export const ScannerOverlay: React.FC<ScannerOverlayProps> = ({
109
100
  lineWidth = StyleSheet.hairlineWidth,
110
101
  polygon,
111
102
  }) => {
112
- const path = useMemo(() => createPolygonPath(polygon ?? null), [polygon]);
113
- const gridPaths = useMemo(() => createGridPaths(polygon ?? null), [polygon]);
114
- const metrics = useMemo(() => (polygon ? getPolygonMetrics(polygon) : null), [polygon]);
115
-
116
- const gradientStart = useValue(vec(0, 0));
117
- const gradientEnd = useValue(vec(0, 0));
118
- const gradientColors = useValue<number[]>([
119
- Skia.Color(withAlpha(color, 0)),
120
- Skia.Color(withAlpha(color, 0.85)),
121
- Skia.Color(withAlpha(color, 0)),
122
- ]);
123
- const gradientPositions = useValue<number[]>([0, 0.5, 1]);
103
+ const scanProgress = useRef(new Animated.Value(0)).current;
104
+ const fallbackBase = useRef(new Animated.Value(0)).current;
124
105
 
125
- useEffect(() => {
126
- if (!metrics) {
127
- return;
128
- }
106
+ const metrics = useMemo(() => (polygon ? calculateMetrics(polygon) : null), [polygon]);
129
107
 
130
- let frame: number | null = null;
131
- const transparentColor = Skia.Color(withAlpha(color, 0));
132
- const highlightColor = Skia.Color(withAlpha(color, 0.9));
133
- const bandSize = Math.max(metrics.height * 0.25, 20);
134
-
135
- const animate = () => {
136
- const now = Date.now() % SCAN_DURATION_MS;
137
- const progress = now / SCAN_DURATION_MS;
138
- const travel = metrics.height + bandSize * 2;
139
- const start = metrics.minY - bandSize + travel * progress;
140
- const end = start + bandSize;
141
-
142
- const clampedStart = clamp(start, metrics.minY, metrics.maxY);
143
- const clampedEnd = clamp(end, metrics.minY, metrics.maxY);
144
-
145
- gradientStart.current = vec(metrics.centerX, clampedStart);
146
- gradientEnd.current = vec(
147
- metrics.centerX,
148
- clampedEnd <= clampedStart ? clampedStart + 1 : clampedEnd,
149
- );
150
- gradientColors.current = [transparentColor, highlightColor, transparentColor];
151
-
152
- frame = requestAnimationFrame(animate);
153
- };
108
+ const scanBarHeight = useMemo(() => {
109
+ if (!metrics) return 0;
110
+ return Math.max(metrics.height * 0.2, 16);
111
+ }, [metrics]);
154
112
 
155
- gradientStart.current = vec(metrics.centerX, metrics.minY);
156
- gradientEnd.current = vec(metrics.centerX, metrics.maxY);
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]);
157
123
 
158
- if (active) {
159
- animate();
160
- } else {
161
- gradientColors.current = [transparentColor, transparentColor, transparentColor];
124
+ useEffect(() => {
125
+ if (!active || !metrics || metrics.height <= 1) {
126
+ scanProgress.stopAnimation();
127
+ scanProgress.setValue(0);
128
+ return undefined;
162
129
  }
163
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();
164
149
  return () => {
165
- if (frame !== null) {
166
- cancelAnimationFrame(frame);
167
- }
150
+ loop.stop();
168
151
  };
169
- }, [active, color, gradientColors, gradientEnd, gradientStart, metrics]);
152
+ }, [active, metrics, scanProgress]);
170
153
 
171
- if (!polygon || !path || !metrics) {
154
+ if (!polygon || !metrics || metrics.width <= 0 || metrics.height <= 0) {
172
155
  return null;
173
156
  }
174
157
 
175
- const strokeColor = withAlpha(color, 0.9);
176
- const fillColor = withAlpha(color, 0.18);
177
- const gridColor = withAlpha(color, 0.35);
158
+ 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);
164
+
165
+ return (
166
+ <View pointerEvents="none" style={StyleSheet.absoluteFill}>
167
+ <Svg style={StyleSheet.absoluteFill}>
168
+ <Polygon points={points} fill={color} opacity={0.15} />
169
+ {gridLines.map((line, index) => (
170
+ <Line
171
+ key={`grid-${index}`}
172
+ x1={line.x1}
173
+ y1={line.y1}
174
+ x2={line.x2}
175
+ y2={line.y2}
176
+ stroke={color}
177
+ strokeWidth={lineWidth}
178
+ opacity={0.5}
179
+ />
180
+ ))}
181
+ <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
+ </Svg>
199
+ </View>
200
+ );
201
+ }
202
+
203
+ // Fallback rendering without react-native-svg
204
+ const relativeTranslate =
205
+ scanTranslate != null ? Animated.subtract(scanTranslate, metrics.minY) : fallbackBase;
178
206
 
179
207
  return (
180
- <View pointerEvents="none" style={StyleSheet.absoluteFillObject}>
181
- <Canvas style={StyleSheet.absoluteFillObject}>
182
- <Path path={path} color={fillColor} style="fill" />
183
- {gridPaths.map((gridPath, index) => (
184
- <Path key={`grid-${index}`} path={gridPath} color={gridColor} style="stroke" strokeWidth={lineWidth} />
185
- ))}
186
- <Path path={path} color={strokeColor} style="stroke" strokeWidth={lineWidth} />
187
- <Path path={path}>
188
- <LinearGradient
189
- start={gradientStart}
190
- end={gradientEnd}
191
- colors={gradientColors}
192
- positions={gradientPositions}
208
+ <View pointerEvents="none" style={StyleSheet.absoluteFill}>
209
+ <View
210
+ style={[
211
+ styles.fallbackBox,
212
+ {
213
+ left: metrics.minX,
214
+ top: metrics.minY,
215
+ width: metrics.width,
216
+ height: metrics.height,
217
+ borderColor: color,
218
+ borderWidth: lineWidth,
219
+ },
220
+ ]}
221
+ >
222
+ {active && (
223
+ <Animated.View
224
+ style={[
225
+ styles.fallbackScanBar,
226
+ {
227
+ backgroundColor: color,
228
+ height: scanBarHeight,
229
+ transform: [{ translateY: relativeTranslate }],
230
+ },
231
+ ]}
193
232
  />
194
- </Path>
195
- </Canvas>
233
+ )}
234
+ </View>
196
235
  </View>
197
236
  );
198
237
  };
238
+
239
+ const styles = StyleSheet.create({
240
+ fallbackBox: {
241
+ position: 'absolute',
242
+ backgroundColor: 'rgba(11, 126, 244, 0.1)',
243
+ overflow: 'hidden',
244
+ },
245
+ fallbackScanBar: {
246
+ width: '100%',
247
+ opacity: 0.4,
248
+ },
249
+ });