react-native-tuto-showcase 1.0.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/LICENSE +0 -0
- package/README.md +0 -0
- package/dist/components/ContentSection.d.ts +13 -0
- package/dist/components/ContentSection.js +35 -0
- package/dist/components/GestureHints.d.ts +12 -0
- package/dist/components/GestureHints.js +19 -0
- package/dist/components/LottieAboveTarget.d.ts +6 -0
- package/dist/components/LottieAboveTarget.js +30 -0
- package/dist/components/Overlay.d.ts +12 -0
- package/dist/components/Overlay.js +30 -0
- package/dist/helpers/buildActions.d.ts +13 -0
- package/dist/helpers/buildActions.js +184 -0
- package/dist/helpers/geometry.d.ts +10 -0
- package/dist/helpers/geometry.js +10 -0
- package/dist/helpers/measureInWindowAsync.d.ts +2 -0
- package/dist/helpers/measureInWindowAsync.js +15 -0
- package/dist/hooks/useContentPosition.d.ts +5 -0
- package/dist/hooks/useContentPosition.js +23 -0
- package/dist/hooks/useOverlayMeasurements.d.ts +14 -0
- package/dist/hooks/useOverlayMeasurements.js +24 -0
- package/dist/hooks/usePulseAnimation.d.ts +5 -0
- package/dist/hooks/usePulseAnimation.js +46 -0
- package/dist/hooks/useShowOnce.d.ts +7 -0
- package/dist/hooks/useShowOnce.js +43 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +92 -0
- package/dist/types/RRSizeOpts.d.ts +9 -0
- package/dist/types/RRSizeOpts.js +1 -0
- package/dist/types/shapes.d.ts +19 -0
- package/dist/types/shapes.js +1 -0
- package/dist/types/viewActions.d.ts +19 -0
- package/dist/types/viewActions.js +1 -0
- package/package.json +38 -0
package/LICENSE
ADDED
|
File without changes
|
package/README.md
ADDED
|
File without changes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
export default function ContentSection({ title, description, buttonText, buttonTextStyle, buttonContainerStyle, coords, onPress, }: {
|
|
3
|
+
title?: React.ReactNode;
|
|
4
|
+
description?: React.ReactNode;
|
|
5
|
+
buttonText?: string;
|
|
6
|
+
buttonTextStyle?: any;
|
|
7
|
+
buttonContainerStyle?: any;
|
|
8
|
+
coords: {
|
|
9
|
+
top?: number;
|
|
10
|
+
bottom?: number;
|
|
11
|
+
};
|
|
12
|
+
onPress: () => void;
|
|
13
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { View, Text, Pressable, StyleSheet } from 'react-native';
|
|
3
|
+
export default function ContentSection({ title, description, buttonText, buttonTextStyle, buttonContainerStyle, coords, onPress, }) {
|
|
4
|
+
return (_jsxs(View, { style: [
|
|
5
|
+
styles.contentBase,
|
|
6
|
+
coords,
|
|
7
|
+
], children: [title ? _jsx(View, { style: styles.titleContainer, children: title }) : null, description ? (_jsx(View, { style: styles.description, children: description })) : null, _jsx(Pressable, { style: [styles.cta, buttonContainerStyle], onPress: onPress, children: _jsx(Text, { style: [styles.ctaText, buttonTextStyle], children: buttonText || 'GOT IT' }) })] }));
|
|
8
|
+
}
|
|
9
|
+
const styles = StyleSheet.create({
|
|
10
|
+
contentBase: {
|
|
11
|
+
position: 'absolute',
|
|
12
|
+
left: 24,
|
|
13
|
+
right: 24,
|
|
14
|
+
alignItems: 'flex-end',
|
|
15
|
+
},
|
|
16
|
+
titleContainer: {
|
|
17
|
+
marginBottom: 8,
|
|
18
|
+
alignItems: 'flex-end',
|
|
19
|
+
},
|
|
20
|
+
description: {
|
|
21
|
+
color: '#FFFFFF',
|
|
22
|
+
fontSize: 16,
|
|
23
|
+
textAlign: 'right',
|
|
24
|
+
opacity: 0.9,
|
|
25
|
+
marginBottom: 24,
|
|
26
|
+
},
|
|
27
|
+
cta: {
|
|
28
|
+
backgroundColor: '#E8E8E8',
|
|
29
|
+
paddingHorizontal: 20,
|
|
30
|
+
paddingVertical: 10,
|
|
31
|
+
borderRadius: 6,
|
|
32
|
+
elevation: 2,
|
|
33
|
+
},
|
|
34
|
+
ctaText: { color: '#333', fontSize: 14, fontWeight: '700' },
|
|
35
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type GestureHint = {
|
|
2
|
+
kind: 'left' | 'right';
|
|
3
|
+
x: number;
|
|
4
|
+
y: number;
|
|
5
|
+
} | {
|
|
6
|
+
kind: 'scroll';
|
|
7
|
+
x: number;
|
|
8
|
+
y: number;
|
|
9
|
+
};
|
|
10
|
+
export default function GestureHints({ hints }: {
|
|
11
|
+
hints: GestureHint[];
|
|
12
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { View, StyleSheet } from 'react-native';
|
|
3
|
+
export default function GestureHints({ hints }) {
|
|
4
|
+
return (_jsx(_Fragment, { children: hints.map((h, i) => (_jsx(View, { style: [styles.hintDot, { left: h.x - 12, top: h.y - 12 }] }, i))) }));
|
|
5
|
+
}
|
|
6
|
+
const styles = StyleSheet.create({
|
|
7
|
+
hintDot: {
|
|
8
|
+
position: 'absolute',
|
|
9
|
+
width: 24,
|
|
10
|
+
height: 24,
|
|
11
|
+
borderRadius: 12,
|
|
12
|
+
backgroundColor: '#FFF',
|
|
13
|
+
elevation: 4,
|
|
14
|
+
shadowColor: '#000',
|
|
15
|
+
shadowOpacity: 0.25,
|
|
16
|
+
shadowRadius: 4,
|
|
17
|
+
shadowOffset: { width: 0, height: 2 },
|
|
18
|
+
},
|
|
19
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { View, StyleSheet } from 'react-native';
|
|
4
|
+
export default function LottieAboveTarget({ shapes, lottie, }) {
|
|
5
|
+
if (!lottie || shapes.length === 0)
|
|
6
|
+
return null;
|
|
7
|
+
const focus = shapes[shapes.length - 1];
|
|
8
|
+
const layout = StyleSheet.flatten(lottie.props?.style) || {};
|
|
9
|
+
const lw = layout.width ?? 100;
|
|
10
|
+
const lh = layout.height ?? 100;
|
|
11
|
+
const cx = focus.cx;
|
|
12
|
+
const topOfHole = focus.type === 'circle' ? focus.cy - focus.r : focus.y;
|
|
13
|
+
const top = topOfHole - lh + 50;
|
|
14
|
+
const left = cx - lw / 2;
|
|
15
|
+
return (_jsx(View, { pointerEvents: "none", style: [
|
|
16
|
+
styles.container,
|
|
17
|
+
{ top, left, width: lw, height: lh },
|
|
18
|
+
], children: React.cloneElement(lottie, {
|
|
19
|
+
autoPlay: true,
|
|
20
|
+
loop: true,
|
|
21
|
+
style: [{ width: lw, height: lh }],
|
|
22
|
+
}) }));
|
|
23
|
+
}
|
|
24
|
+
const styles = StyleSheet.create({
|
|
25
|
+
container: {
|
|
26
|
+
position: 'absolute',
|
|
27
|
+
justifyContent: 'center',
|
|
28
|
+
alignItems: 'center',
|
|
29
|
+
},
|
|
30
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Animated } from 'react-native';
|
|
2
|
+
import { AnyShape } from '../types/shapes';
|
|
3
|
+
type Props = {
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
shapes: AnyShape[];
|
|
7
|
+
bgColor: string;
|
|
8
|
+
spotScale: Animated.Value;
|
|
9
|
+
spotOpacity: Animated.Value;
|
|
10
|
+
};
|
|
11
|
+
export default function Overlay({ width, height, shapes, bgColor, spotScale, spotOpacity, }: Props): import("react/jsx-runtime").JSX.Element;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import Svg, { Rect, Circle, Defs, Mask, G } from 'react-native-svg';
|
|
3
|
+
import { Animated } from 'react-native';
|
|
4
|
+
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
|
|
5
|
+
export default function Overlay({ width, height, shapes, bgColor, spotScale, spotOpacity, }) {
|
|
6
|
+
const maskId = 'tutoMask';
|
|
7
|
+
const baseR = 7; // SPOT_BASE_SIZE/2
|
|
8
|
+
const animatedR = Animated.multiply(baseR, spotScale);
|
|
9
|
+
const getAlphaColor = (color) => color.replace(/rgba?\(([^)]+)\)/, (m, inner) => {
|
|
10
|
+
const p = inner.split(',').map((s) => s.trim());
|
|
11
|
+
if (p.length >= 4) {
|
|
12
|
+
p[3] = '0.3';
|
|
13
|
+
return `rgba(${p.join(',')})`;
|
|
14
|
+
}
|
|
15
|
+
return `rgba(${p.join(',')},0.3)`;
|
|
16
|
+
});
|
|
17
|
+
return (_jsxs(Svg, { width: width, height: height, children: [_jsx(Defs, { children: _jsxs(Mask, { id: maskId, children: [_jsx(Rect, { x: 0, y: 0, width: width, height: height, fill: "white" }), shapes.map((s, i) => s.type === 'circle' ? (_jsx(Circle, { cx: s.cx, cy: s.cy, r: s.r, fill: "black" }, i)) : (_jsx(Rect, { x: s.x, y: s.y, width: s.w, height: s.h, rx: s.radius ?? s.h / 2, ry: s.radius ?? s.h / 2, fill: "black" }, i)))] }) }), _jsx(Rect, { x: 0, y: 0, width: width, height: height, fill: bgColor, mask: `url(#${maskId})` }), shapes.map((s, i) => {
|
|
18
|
+
const alpha = getAlphaColor(bgColor);
|
|
19
|
+
return (_jsx(AnimatedCircle, { cx: s.cx, cy: s.cy, r: animatedR, fill: alpha, opacity: spotOpacity }, 'spot-' + i));
|
|
20
|
+
}), _jsx(G, { children: shapes.map((s, i) => {
|
|
21
|
+
if (!s.border)
|
|
22
|
+
return null;
|
|
23
|
+
const color = s.borderColor ?? '#AA999999';
|
|
24
|
+
if (s.type === 'circle') {
|
|
25
|
+
return (_jsx(Circle, { cx: s.cx, cy: s.cy, r: s.r * 1.2, stroke: color, strokeWidth: 2, fill: "transparent" }, 'border-' + i));
|
|
26
|
+
}
|
|
27
|
+
const pad = 30;
|
|
28
|
+
return (_jsx(Rect, { x: s.x - pad, y: s.y - pad, width: s.w + pad * 2, height: s.h + pad * 2, rx: (s.h + pad * 2) / 2, ry: (s.h + pad * 2) / 2, stroke: color, strokeWidth: 2, fill: "transparent" }, 'border-' + i));
|
|
29
|
+
}) })] }));
|
|
30
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { AnyShape } from '../types/shapes';
|
|
2
|
+
import { ViewActions } from '../types/viewActions';
|
|
3
|
+
import React from 'react';
|
|
4
|
+
export declare function buildActionsFactory(setVisible: (b: boolean) => void, measureOverlayOrigin: () => void, setShapes: React.Dispatch<React.SetStateAction<AnyShape[]>>, setHints: React.Dispatch<React.SetStateAction<any[]>>, setClickZones: React.Dispatch<React.SetStateAction<Array<{
|
|
5
|
+
x: number;
|
|
6
|
+
y: number;
|
|
7
|
+
w: number;
|
|
8
|
+
h: number;
|
|
9
|
+
onClick?: () => void;
|
|
10
|
+
}>>>, overlayOrigin: {
|
|
11
|
+
x: number;
|
|
12
|
+
y: number;
|
|
13
|
+
}, expose: any): (refOrTag: React.RefObject<any> | number | null) => ViewActions;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { Dimensions, findNodeHandle, Platform } from 'react-native';
|
|
2
|
+
import { measureInWindowAsync } from './measureInWindowAsync';
|
|
3
|
+
import { toLocalRect } from './geometry';
|
|
4
|
+
const DEFAULT_ADDITIONAL_RADIUS_RATIO = 1;
|
|
5
|
+
const BORDER_PAD = 30;
|
|
6
|
+
class ViewActionsSettings {
|
|
7
|
+
animated = true;
|
|
8
|
+
withBorder = false;
|
|
9
|
+
onClick;
|
|
10
|
+
delay = 0;
|
|
11
|
+
duration = 300;
|
|
12
|
+
}
|
|
13
|
+
export function buildActionsFactory(setVisible, measureOverlayOrigin, setShapes, setHints, setClickZones, overlayOrigin, expose) {
|
|
14
|
+
return function buildActions(refOrTag) {
|
|
15
|
+
const settings = new ViewActionsSettings();
|
|
16
|
+
const resolveRect = async () => {
|
|
17
|
+
if (!refOrTag)
|
|
18
|
+
return null;
|
|
19
|
+
const getTag = () => typeof refOrTag === 'number'
|
|
20
|
+
? refOrTag
|
|
21
|
+
: findNodeHandle(refOrTag.current);
|
|
22
|
+
const tryMeasure = async (attempt = 0) => {
|
|
23
|
+
const tag = getTag();
|
|
24
|
+
if (!tag)
|
|
25
|
+
return null;
|
|
26
|
+
const rect = await measureInWindowAsync(tag).catch(() => null);
|
|
27
|
+
if (!rect)
|
|
28
|
+
return null;
|
|
29
|
+
const screenH = Dimensions.get('window').height;
|
|
30
|
+
const isVisible = rect.height > 0 && rect.y < screenH && rect.y + rect.height > 0;
|
|
31
|
+
if (!isVisible && attempt < 10) {
|
|
32
|
+
await new Promise(r => setTimeout(r, 100));
|
|
33
|
+
return tryMeasure(attempt + 1);
|
|
34
|
+
}
|
|
35
|
+
return rect;
|
|
36
|
+
};
|
|
37
|
+
return await tryMeasure();
|
|
38
|
+
};
|
|
39
|
+
const editor = (api) => api;
|
|
40
|
+
const actionEditor = (api) => api;
|
|
41
|
+
const api = {
|
|
42
|
+
on: next => buildActions(next),
|
|
43
|
+
show: () => {
|
|
44
|
+
setVisible(true);
|
|
45
|
+
requestAnimationFrame(() => measureOverlayOrigin());
|
|
46
|
+
return expose;
|
|
47
|
+
},
|
|
48
|
+
showOnce: key => {
|
|
49
|
+
expose.showOnce(key);
|
|
50
|
+
return expose;
|
|
51
|
+
},
|
|
52
|
+
onClickContentView: (_idOrRef, onClick) => {
|
|
53
|
+
resolveRect().then(r => {
|
|
54
|
+
if (!r)
|
|
55
|
+
return;
|
|
56
|
+
const lr = toLocalRect(r, overlayOrigin);
|
|
57
|
+
setClickZones(p => [
|
|
58
|
+
...p,
|
|
59
|
+
{ x: lr.x, y: lr.y, w: lr.width, h: lr.height, onClick }
|
|
60
|
+
]);
|
|
61
|
+
});
|
|
62
|
+
return expose;
|
|
63
|
+
},
|
|
64
|
+
addCircle: (ratio = DEFAULT_ADDITIONAL_RADIUS_RATIO) => {
|
|
65
|
+
resolveRect().then(r => {
|
|
66
|
+
if (!r)
|
|
67
|
+
return;
|
|
68
|
+
const lr = toLocalRect(r, overlayOrigin);
|
|
69
|
+
const cx = lr.x + lr.width / 2;
|
|
70
|
+
const cy = lr.y + lr.height / 2;
|
|
71
|
+
const radius = (Math.max(lr.width, lr.height) / 2) * ratio;
|
|
72
|
+
setShapes(p => [...p, { type: 'circle', cx, cy, r: radius }]);
|
|
73
|
+
const w = lr.width * ratio;
|
|
74
|
+
const h = lr.height * ratio;
|
|
75
|
+
const x = lr.x - (w - lr.width) / 2;
|
|
76
|
+
const y = lr.y - (h - lr.height) / 2;
|
|
77
|
+
setClickZones(p => [...p, { x, y, w, h, onClick: settings.onClick }]);
|
|
78
|
+
});
|
|
79
|
+
return editor(api);
|
|
80
|
+
},
|
|
81
|
+
addRoundRect: (ratio = DEFAULT_ADDITIONAL_RADIUS_RATIO, radius, opts) => {
|
|
82
|
+
resolveRect().then(r => {
|
|
83
|
+
if (!r)
|
|
84
|
+
return;
|
|
85
|
+
const lr = toLocalRect(r, overlayOrigin);
|
|
86
|
+
const defaultPad = Platform.OS === 'android' ? 24 : 40;
|
|
87
|
+
const pad = opts?.pad ?? defaultPad;
|
|
88
|
+
const baseCx = lr.x + lr.width / 2;
|
|
89
|
+
const baseCy = lr.y + lr.height / 2;
|
|
90
|
+
let w = lr.width + 2 * pad;
|
|
91
|
+
let h = lr.height + 2 * pad;
|
|
92
|
+
if (typeof opts?.w === 'number')
|
|
93
|
+
w = opts.w;
|
|
94
|
+
if (typeof opts?.h === 'number')
|
|
95
|
+
h = opts.h;
|
|
96
|
+
if (typeof opts?.dw === 'number')
|
|
97
|
+
w = w + opts.dw;
|
|
98
|
+
if (typeof opts?.dh === 'number')
|
|
99
|
+
h = h + opts.dh;
|
|
100
|
+
w = Math.max(1, w);
|
|
101
|
+
h = Math.max(1, h);
|
|
102
|
+
let x = baseCx - w / 2;
|
|
103
|
+
let y = baseCy - h / 2;
|
|
104
|
+
if (typeof opts?.dx === 'number')
|
|
105
|
+
x += opts.dx;
|
|
106
|
+
if (typeof opts?.dy === 'number')
|
|
107
|
+
y += opts.dy;
|
|
108
|
+
const cx = x + w / 2;
|
|
109
|
+
const cy = y + h / 2;
|
|
110
|
+
setShapes(p => [
|
|
111
|
+
...p,
|
|
112
|
+
{ type: 'roundrect', x, y, w, h, cx, cy, radius }
|
|
113
|
+
]);
|
|
114
|
+
setClickZones(p => [...p, { x, y, w, h, onClick: settings.onClick }]);
|
|
115
|
+
});
|
|
116
|
+
return editor(api);
|
|
117
|
+
},
|
|
118
|
+
displaySwipableLeft: () => {
|
|
119
|
+
resolveRect().then(r => {
|
|
120
|
+
if (!r)
|
|
121
|
+
return;
|
|
122
|
+
const lr = toLocalRect(r, overlayOrigin);
|
|
123
|
+
setHints(p => [
|
|
124
|
+
...p,
|
|
125
|
+
{ kind: 'left', x: lr.x + lr.width * 0.7, y: lr.y + lr.height / 2 }
|
|
126
|
+
]);
|
|
127
|
+
});
|
|
128
|
+
return actionEditor(api);
|
|
129
|
+
},
|
|
130
|
+
displaySwipableRight: () => {
|
|
131
|
+
resolveRect().then(r => {
|
|
132
|
+
if (!r)
|
|
133
|
+
return;
|
|
134
|
+
const lr = toLocalRect(r, overlayOrigin);
|
|
135
|
+
setHints(p => [
|
|
136
|
+
...p,
|
|
137
|
+
{ kind: 'right', x: lr.x, y: lr.y + lr.height / 2 }
|
|
138
|
+
]);
|
|
139
|
+
});
|
|
140
|
+
return actionEditor(api);
|
|
141
|
+
},
|
|
142
|
+
displayScrollable: () => {
|
|
143
|
+
resolveRect().then(r => {
|
|
144
|
+
if (!r)
|
|
145
|
+
return;
|
|
146
|
+
const lr = toLocalRect(r, overlayOrigin);
|
|
147
|
+
setHints(p => [
|
|
148
|
+
...p,
|
|
149
|
+
{ kind: 'scroll', x: lr.x + lr.width / 2, y: lr.y + lr.height * 0.1 }
|
|
150
|
+
]);
|
|
151
|
+
});
|
|
152
|
+
return actionEditor(api);
|
|
153
|
+
},
|
|
154
|
+
withBorder: () => {
|
|
155
|
+
setShapes(prev => {
|
|
156
|
+
if (prev.length === 0)
|
|
157
|
+
return prev;
|
|
158
|
+
const last = { ...prev[prev.length - 1], border: true };
|
|
159
|
+
const arr = [...prev];
|
|
160
|
+
arr[arr.length - 1] = last;
|
|
161
|
+
return arr;
|
|
162
|
+
});
|
|
163
|
+
return editor(api);
|
|
164
|
+
},
|
|
165
|
+
onClick: cb => {
|
|
166
|
+
settings.onClick = cb;
|
|
167
|
+
return editor(api);
|
|
168
|
+
},
|
|
169
|
+
delayed: ms => {
|
|
170
|
+
settings.delay = ms;
|
|
171
|
+
return actionEditor(api);
|
|
172
|
+
},
|
|
173
|
+
duration: ms => {
|
|
174
|
+
settings.duration = ms;
|
|
175
|
+
return actionEditor(api);
|
|
176
|
+
},
|
|
177
|
+
animated: a => {
|
|
178
|
+
settings.animated = a;
|
|
179
|
+
return actionEditor(api);
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
return api;
|
|
183
|
+
};
|
|
184
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Platform, StatusBar } from 'react-native';
|
|
2
|
+
export function toLocalRect(r, overlayOrigin) {
|
|
3
|
+
let x = r.x - overlayOrigin.x;
|
|
4
|
+
let y = r.y - overlayOrigin.y;
|
|
5
|
+
if (Platform.OS === 'android') {
|
|
6
|
+
const sb = StatusBar.currentHeight ?? 0;
|
|
7
|
+
y = y + sb;
|
|
8
|
+
}
|
|
9
|
+
return { x, y, width: r.width, height: r.height };
|
|
10
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { UIManager } from 'react-native';
|
|
2
|
+
export function measureInWindowAsync(nativeTag) {
|
|
3
|
+
return new Promise((resolve, reject) => {
|
|
4
|
+
if (!nativeTag)
|
|
5
|
+
return reject(new Error('Invalid native tag'));
|
|
6
|
+
UIManager.measureInWindow(nativeTag, (x, y, w, h) => {
|
|
7
|
+
if (typeof x === 'number') {
|
|
8
|
+
resolve({ x, y, width: w, height: h });
|
|
9
|
+
}
|
|
10
|
+
else {
|
|
11
|
+
reject(new Error('measureInWindow failed'));
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
export function useContentPosition(visible, shapes, height) {
|
|
3
|
+
const [coords, setCoords] = useState({});
|
|
4
|
+
useEffect(() => {
|
|
5
|
+
if (!visible || shapes.length === 0) {
|
|
6
|
+
setCoords({ bottom: 32 });
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
const s = shapes[shapes.length - 1];
|
|
10
|
+
const centerY = s.cy;
|
|
11
|
+
const shapeHalf = s.type === 'circle' ? s.r : s.h / 2;
|
|
12
|
+
const margin = 50; // المسافة بين الهول والمحتوى
|
|
13
|
+
if (centerY < height / 2) {
|
|
14
|
+
const top = centerY + shapeHalf + margin;
|
|
15
|
+
setCoords({ top });
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
const bottom = height - (centerY - shapeHalf) + margin;
|
|
19
|
+
setCoords({ bottom });
|
|
20
|
+
}
|
|
21
|
+
}, [visible, shapes, height]);
|
|
22
|
+
return coords;
|
|
23
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { LayoutChangeEvent } from 'react-native';
|
|
2
|
+
export declare function useOverlayMeasurements(): {
|
|
3
|
+
rootRef: import("react").RefObject<any>;
|
|
4
|
+
overlayOrigin: {
|
|
5
|
+
x: number;
|
|
6
|
+
y: number;
|
|
7
|
+
};
|
|
8
|
+
overlaySize: {
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
};
|
|
12
|
+
onLayout: (e: LayoutChangeEvent) => void;
|
|
13
|
+
measureOverlayOrigin: () => Promise<void>;
|
|
14
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from 'react';
|
|
2
|
+
import { findNodeHandle } from 'react-native';
|
|
3
|
+
import { measureInWindowAsync } from '../helpers/measureInWindowAsync';
|
|
4
|
+
export function useOverlayMeasurements() {
|
|
5
|
+
const rootRef = useRef(null);
|
|
6
|
+
const [overlayOrigin, setOverlayOrigin] = useState({ x: 0, y: 0 });
|
|
7
|
+
const [overlaySize, setOverlaySize] = useState({ width: 0, height: 0 });
|
|
8
|
+
const measureOverlayOrigin = useCallback(async () => {
|
|
9
|
+
const tag = findNodeHandle(rootRef.current);
|
|
10
|
+
if (!tag)
|
|
11
|
+
return;
|
|
12
|
+
try {
|
|
13
|
+
const rect = await measureInWindowAsync(tag);
|
|
14
|
+
setOverlayOrigin({ x: rect.x, y: rect.y });
|
|
15
|
+
}
|
|
16
|
+
catch { }
|
|
17
|
+
}, []);
|
|
18
|
+
const onLayout = useCallback((e) => {
|
|
19
|
+
const { width, height } = e.nativeEvent.layout;
|
|
20
|
+
setOverlaySize({ width, height });
|
|
21
|
+
measureOverlayOrigin();
|
|
22
|
+
}, [measureOverlayOrigin]);
|
|
23
|
+
return { rootRef, overlayOrigin, overlaySize, onLayout, measureOverlayOrigin };
|
|
24
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
import { Animated, Easing } from 'react-native';
|
|
3
|
+
const SPOT_PULSE_MS = 900;
|
|
4
|
+
const SPOT_SCALE_MIN = 0.85;
|
|
5
|
+
const SPOT_SCALE_MAX = 1.25;
|
|
6
|
+
export function usePulseAnimation(visible) {
|
|
7
|
+
const scale = useRef(new Animated.Value(1)).current;
|
|
8
|
+
const opacity = useRef(new Animated.Value(1)).current;
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
if (!visible)
|
|
11
|
+
return;
|
|
12
|
+
const loop = Animated.loop(Animated.sequence([
|
|
13
|
+
Animated.parallel([
|
|
14
|
+
Animated.timing(scale, {
|
|
15
|
+
toValue: SPOT_SCALE_MAX,
|
|
16
|
+
duration: SPOT_PULSE_MS,
|
|
17
|
+
easing: Easing.out(Easing.quad),
|
|
18
|
+
useNativeDriver: true
|
|
19
|
+
}),
|
|
20
|
+
Animated.timing(opacity, {
|
|
21
|
+
toValue: 0.85,
|
|
22
|
+
duration: SPOT_PULSE_MS,
|
|
23
|
+
easing: Easing.out(Easing.quad),
|
|
24
|
+
useNativeDriver: true
|
|
25
|
+
})
|
|
26
|
+
]),
|
|
27
|
+
Animated.parallel([
|
|
28
|
+
Animated.timing(scale, {
|
|
29
|
+
toValue: SPOT_SCALE_MIN,
|
|
30
|
+
duration: SPOT_PULSE_MS,
|
|
31
|
+
easing: Easing.in(Easing.quad),
|
|
32
|
+
useNativeDriver: true
|
|
33
|
+
}),
|
|
34
|
+
Animated.timing(opacity, {
|
|
35
|
+
toValue: 1,
|
|
36
|
+
duration: SPOT_PULSE_MS,
|
|
37
|
+
easing: Easing.in(Easing.quad),
|
|
38
|
+
useNativeDriver: true
|
|
39
|
+
})
|
|
40
|
+
])
|
|
41
|
+
]));
|
|
42
|
+
loop.start();
|
|
43
|
+
return () => loop.stop();
|
|
44
|
+
}, [visible]);
|
|
45
|
+
return { scale, opacity };
|
|
46
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
3
|
+
const STORAGE_KEY = '@tuto_showcase_shown_keys';
|
|
4
|
+
export function useShowOnce() {
|
|
5
|
+
const [shownKeys, setShownKeys] = useState(new Set());
|
|
6
|
+
const [loaded, setLoaded] = useState(false);
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
(async () => {
|
|
9
|
+
try {
|
|
10
|
+
const stored = await AsyncStorage.getItem(STORAGE_KEY);
|
|
11
|
+
if (stored) {
|
|
12
|
+
setShownKeys(new Set(JSON.parse(stored)));
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
catch { }
|
|
16
|
+
setLoaded(true);
|
|
17
|
+
})();
|
|
18
|
+
}, []);
|
|
19
|
+
const persist = async (keys) => {
|
|
20
|
+
try {
|
|
21
|
+
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(Array.from(keys)));
|
|
22
|
+
}
|
|
23
|
+
catch { }
|
|
24
|
+
};
|
|
25
|
+
const markShown = (key) => {
|
|
26
|
+
setShownKeys(prev => {
|
|
27
|
+
const set = new Set(prev);
|
|
28
|
+
set.add(key);
|
|
29
|
+
persist(set);
|
|
30
|
+
return set;
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
const reset = (key) => {
|
|
34
|
+
setShownKeys(prev => {
|
|
35
|
+
const set = new Set(prev);
|
|
36
|
+
set.delete(key);
|
|
37
|
+
persist(set);
|
|
38
|
+
return set;
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
const isShown = (key) => shownKeys.has(key);
|
|
42
|
+
return { shownKeys, loaded, markShown, reset, isShown };
|
|
43
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
export type TutoShowcaseHandle = {
|
|
3
|
+
on: (r: any) => any;
|
|
4
|
+
show: () => void;
|
|
5
|
+
dismiss: () => void;
|
|
6
|
+
showOnce: (key: string) => void;
|
|
7
|
+
isShowOnce: (key: string) => boolean;
|
|
8
|
+
resetShowOnce: (key: string) => void;
|
|
9
|
+
};
|
|
10
|
+
declare const TutoShowcase: React.ForwardRefExoticComponent<Omit<any, "ref"> & React.RefAttributes<TutoShowcaseHandle>>;
|
|
11
|
+
export default TutoShowcase;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, useState, useCallback, useImperativeHandle, useEffect, } from 'react';
|
|
3
|
+
import { Modal, TouchableWithoutFeedback, View, StyleSheet, Dimensions, } from 'react-native';
|
|
4
|
+
import Overlay from './components/Overlay';
|
|
5
|
+
import GestureHints from './components/GestureHints';
|
|
6
|
+
import ContentSection from './components/ContentSection';
|
|
7
|
+
import LottieAboveTarget from './components/LottieAboveTarget';
|
|
8
|
+
import { useShowOnce } from './hooks/useShowOnce';
|
|
9
|
+
import { useOverlayMeasurements } from './hooks/useOverlayMeasurements';
|
|
10
|
+
import { usePulseAnimation } from './hooks/usePulseAnimation';
|
|
11
|
+
import { buildActionsFactory } from './helpers/buildActions';
|
|
12
|
+
import { useContentPosition } from './hooks/useContentPosition';
|
|
13
|
+
const DEFAULT_BG = 'rgba(0,0,0,0.78)';
|
|
14
|
+
const TutoShowcase = forwardRef(function Tuto({ title, description, buttonText = 'GOT IT', buttonTextStyle, buttonContainerStyle, overlayBackgroundColor, onGotIt, lottie, }, ref) {
|
|
15
|
+
// -------- Hooks يجب أن تكون في الأعلى وبنفس الترتيب دائماً --------
|
|
16
|
+
const [visible, setVisible] = useState(false);
|
|
17
|
+
const [bgColor, setBgColor] = useState(overlayBackgroundColor || DEFAULT_BG);
|
|
18
|
+
const [shapes, setShapes] = useState([]);
|
|
19
|
+
const [hints, setHints] = useState([]);
|
|
20
|
+
const [clickZones, setClickZones] = useState([]);
|
|
21
|
+
const { rootRef, overlayOrigin, overlaySize, onLayout, measureOverlayOrigin, } = useOverlayMeasurements();
|
|
22
|
+
const { scale: spotScale, opacity: spotOpacity } = usePulseAnimation(visible);
|
|
23
|
+
const dims = Dimensions.get('window');
|
|
24
|
+
const width = overlaySize.width || dims.width;
|
|
25
|
+
const height = overlaySize.height || dims.height;
|
|
26
|
+
const coords = useContentPosition(visible, shapes, height);
|
|
27
|
+
const { shownKeys, loaded, markShown, reset: resetShowOnce, isShown, } = useShowOnce();
|
|
28
|
+
// لو اتغير الـ prop من برّه نحدث الـ state
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (overlayBackgroundColor) {
|
|
31
|
+
setBgColor(overlayBackgroundColor);
|
|
32
|
+
}
|
|
33
|
+
}, [overlayBackgroundColor]);
|
|
34
|
+
const dismiss = useCallback(() => {
|
|
35
|
+
setVisible(false);
|
|
36
|
+
setShapes([]);
|
|
37
|
+
setHints([]);
|
|
38
|
+
setClickZones([]);
|
|
39
|
+
}, []);
|
|
40
|
+
const expose = {
|
|
41
|
+
on: () => { },
|
|
42
|
+
show: () => {
|
|
43
|
+
setVisible(true);
|
|
44
|
+
return;
|
|
45
|
+
},
|
|
46
|
+
dismiss,
|
|
47
|
+
showOnce: key => {
|
|
48
|
+
if (!loaded) {
|
|
49
|
+
setVisible(true);
|
|
50
|
+
markShown(key);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (shownKeys.has(key))
|
|
54
|
+
return;
|
|
55
|
+
setVisible(true);
|
|
56
|
+
markShown(key);
|
|
57
|
+
},
|
|
58
|
+
isShowOnce: key => shownKeys.has(key),
|
|
59
|
+
resetShowOnce,
|
|
60
|
+
};
|
|
61
|
+
const buildActions = buildActionsFactory(setVisible, measureOverlayOrigin, setShapes, setHints, setClickZones, overlayOrigin, expose);
|
|
62
|
+
// نربط on بالـ factory
|
|
63
|
+
expose.on = refOrTag => buildActions(refOrTag);
|
|
64
|
+
useImperativeHandle(ref, () => expose, [
|
|
65
|
+
visible,
|
|
66
|
+
shapes,
|
|
67
|
+
hints,
|
|
68
|
+
clickZones,
|
|
69
|
+
bgColor,
|
|
70
|
+
overlayOrigin,
|
|
71
|
+
shownKeys,
|
|
72
|
+
loaded,
|
|
73
|
+
]);
|
|
74
|
+
const onOverlayPress = useCallback((evt) => {
|
|
75
|
+
const x = evt.nativeEvent.locationX;
|
|
76
|
+
const y = evt.nativeEvent.locationY;
|
|
77
|
+
for (const z of clickZones) {
|
|
78
|
+
if (x >= z.x && x <= z.x + z.w && y >= z.y && y <= z.y + z.h) {
|
|
79
|
+
z.onClick?.();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}, [clickZones]);
|
|
84
|
+
// ✅ الشرط هنا بعد كل الـ hooks
|
|
85
|
+
if (!visible)
|
|
86
|
+
return null;
|
|
87
|
+
return (_jsx(Modal, { visible: true, transparent: true, animationType: "fade", children: _jsx(TouchableWithoutFeedback, { onPress: onOverlayPress, children: _jsxs(View, { ref: rootRef, onLayout: onLayout, style: [StyleSheet.absoluteFill, { direction: 'ltr' }], children: [_jsx(Overlay, { width: width, height: height, shapes: shapes, bgColor: bgColor, spotScale: spotScale, spotOpacity: spotOpacity }), lottie && _jsx(LottieAboveTarget, { shapes: shapes, lottie: lottie }), (title || description) && (_jsx(ContentSection, { title: title, description: description, buttonText: buttonText, buttonTextStyle: buttonTextStyle, buttonContainerStyle: buttonContainerStyle, coords: coords, onPress: () => {
|
|
88
|
+
onGotIt?.();
|
|
89
|
+
dismiss();
|
|
90
|
+
} })), _jsx(GestureHints, { hints: hints })] }) }) }));
|
|
91
|
+
});
|
|
92
|
+
export default TutoShowcase;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type ShapeBase = {
|
|
2
|
+
border?: boolean;
|
|
3
|
+
borderColor?: string;
|
|
4
|
+
cx: number;
|
|
5
|
+
cy: number;
|
|
6
|
+
};
|
|
7
|
+
export type CircleShape = ShapeBase & {
|
|
8
|
+
type: 'circle';
|
|
9
|
+
r: number;
|
|
10
|
+
};
|
|
11
|
+
export type RoundRectShape = ShapeBase & {
|
|
12
|
+
type: 'roundrect';
|
|
13
|
+
x: number;
|
|
14
|
+
y: number;
|
|
15
|
+
w: number;
|
|
16
|
+
h: number;
|
|
17
|
+
radius?: number;
|
|
18
|
+
};
|
|
19
|
+
export type AnyShape = CircleShape | RoundRectShape;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { RRSizeOpts } from './RRSizeOpts';
|
|
2
|
+
export type ViewActions = {
|
|
3
|
+
on: (refOrTag: any) => ViewActions;
|
|
4
|
+
show: () => any;
|
|
5
|
+
showOnce: (key: string) => any;
|
|
6
|
+
onClickContentView: (idOrRef: any, onClick: () => void) => any;
|
|
7
|
+
addCircle: (ratio?: number) => ViewActionsEditor;
|
|
8
|
+
addRoundRect: (ratio?: number, radius?: number, opts?: RRSizeOpts) => ViewActionsEditor;
|
|
9
|
+
displaySwipableLeft: () => ActionViewActionsEditor;
|
|
10
|
+
displaySwipableRight: () => ActionViewActionsEditor;
|
|
11
|
+
displayScrollable: () => ActionViewActionsEditor;
|
|
12
|
+
withBorder: () => ViewActionsEditor;
|
|
13
|
+
onClick: (cb: () => void) => ViewActionsEditor;
|
|
14
|
+
delayed: (ms: number) => ActionViewActionsEditor;
|
|
15
|
+
duration: (ms: number) => ActionViewActionsEditor;
|
|
16
|
+
animated: (a: boolean) => ActionViewActionsEditor;
|
|
17
|
+
};
|
|
18
|
+
export type ViewActionsEditor = Pick<ViewActions, 'withBorder' | 'onClick' | 'on' | 'show' | 'showOnce' | 'onClickContentView'>;
|
|
19
|
+
export type ActionViewActionsEditor = Pick<ViewActions, 'delayed' | 'duration' | 'animated' | 'on' | 'show' | 'showOnce' | 'onClickContentView'>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "react-native-tuto-showcase",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Customizable tutorial / spotlight overlay for React Native (onboarding, feature tours, coachmarks).",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"source": "src/index.tsx",
|
|
8
|
+
"files": ["dist"],
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"react-native",
|
|
14
|
+
"tutorial",
|
|
15
|
+
"onboarding",
|
|
16
|
+
"spotlight",
|
|
17
|
+
"showcase",
|
|
18
|
+
"coachmark",
|
|
19
|
+
"overlay"
|
|
20
|
+
],
|
|
21
|
+
"author": "Ahmed Mohamed Ali Ali Hegazy",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"react": ">=18.0.0",
|
|
25
|
+
"react-native": ">=0.72.0"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@react-native-async-storage/async-storage": "^1.23.0",
|
|
29
|
+
"react-native-svg": "^13.14.0"
|
|
30
|
+
},
|
|
31
|
+
"packageManager": "yarn@3.6.4+sha512.e70835d4d6d62c07be76b3c1529cb640c7443f0fe434ef4b6478a5a399218cbaf1511b396b3c56eb03bc86424cff2320f6167ad2fde273aa0df6e60b7754029f",
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/react": "^19.2.7",
|
|
34
|
+
"@types/react-native": "^0.72.8",
|
|
35
|
+
"typescript": "^5.9.3"
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
}
|