rn-smart-tour 1.0.1 → 1.0.3
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/README.md +90 -7
- package/dist/DapOverlay.js +78 -28
- package/dist/DapProvider.js +72 -36
- package/dist/DapTarget.js +70 -16
- package/dist/types.d.ts +1 -1
- package/package.json +20 -8
package/README.md
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
An enterprise-grade Digital Adoption Platform (DAP) package for React Native. Easily add product tours, guided walkthroughs, and onboarding overlays directly into your app without intrusive code changes.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
|
-
- **
|
|
7
|
-
- **
|
|
6
|
+
- **Multi-Pass Measurement**: Wraps your components and uses the native `measureInWindow` API with a self-correcting, multi-pass strategy — so highlights land correctly even during screen-transition animations.
|
|
7
|
+
- **Rotation & Resize Aware**: Automatically re-measures targets when screen dimensions change (rotation, split-screen, foldables).
|
|
8
|
+
- **Debounced Auto-Start Engine**: Automatically triggers tours when target elements mount, with a built-in debounce to ensure the overlay uses fully settled coordinates.
|
|
8
9
|
- **Seen State Caching**: Connect any local storage database to ensure users only see tours once.
|
|
9
|
-
- **Smart Overlays**: Creates highlighted holes in backdrops over completely custom UIs.
|
|
10
|
+
- **Smart Overlays**: Creates highlighted holes in backdrops over completely custom UIs, with Back/Next/Skip navigation.
|
|
10
11
|
|
|
11
12
|
## Installation
|
|
12
13
|
|
|
@@ -74,7 +75,7 @@ To start the walkthrough, just call `startTour` anywhere inside your app.
|
|
|
74
75
|
|
|
75
76
|
```tsx
|
|
76
77
|
import { Button } from 'react-native';
|
|
77
|
-
import { useDap } from '
|
|
78
|
+
import { useDap } from 'rn-smart-tour';
|
|
78
79
|
|
|
79
80
|
export const HelpMenu = () => {
|
|
80
81
|
const { startTour } = useDap();
|
|
@@ -118,6 +119,58 @@ const myStorage = {
|
|
|
118
119
|
<DapProvider tours={MY_TOURS} storageAdapter={myStorage}>
|
|
119
120
|
```
|
|
120
121
|
|
|
122
|
+
### 2. How Measurement Works
|
|
123
|
+
|
|
124
|
+
When a `<DapTarget>` mounts or re-layouts, it does **not** rely on a single measurement. Instead, it uses a **multi-pass strategy** to guarantee accuracy:
|
|
125
|
+
|
|
126
|
+
| Pass | Delay | Purpose |
|
|
127
|
+
|------|-------|---------|
|
|
128
|
+
| 1st | 100ms | Fast first estimate — may capture mid-animation coordinates |
|
|
129
|
+
| 2nd | 500ms | Self-corrects after most navigation transitions finish |
|
|
130
|
+
| 3rd | 1000ms | Final safety net for slow animations or async layout shifts |
|
|
131
|
+
|
|
132
|
+
- Each new `onLayout` event **cancels** all pending timers and schedules fresh measurements.
|
|
133
|
+
- A measurement is only sent to the Provider if the position has **actually changed** (>1pt threshold), avoiding unnecessary re-renders.
|
|
134
|
+
- On **rotation/resize**, all targets automatically re-measure themselves.
|
|
135
|
+
- On unmount, all timers are cleaned up and the target is unregistered.
|
|
136
|
+
|
|
137
|
+
#### Auto-Start Debounce
|
|
138
|
+
|
|
139
|
+
When `autoStart: true` is set on a tour, the overlay does **not** appear the instant a target registers. Instead, it waits **300ms** before starting. If a more accurate measurement arrives during that window (e.g., the 500ms pass corrects the 100ms pass), the debounce resets and the overlay uses the final, settled coordinates.
|
|
140
|
+
|
|
141
|
+
This is why you may notice a brief (~300ms) delay before an auto-start tour appears — it's by design to prevent misaligned highlights.
|
|
142
|
+
|
|
143
|
+
#### Tuning Constants
|
|
144
|
+
|
|
145
|
+
If you need to adjust timing for your app's specific animation durations, the following constants can be modified in the source:
|
|
146
|
+
|
|
147
|
+
| Constant | File | Default | Description |
|
|
148
|
+
|----------|------|---------|-------------|
|
|
149
|
+
| `MEASUREMENT_DELAYS` | `DapTarget.tsx` | `[100, 500, 1000]` | Multi-pass measurement intervals (ms) |
|
|
150
|
+
| `POSITION_THRESHOLD` | `DapTarget.tsx` | `1` | Minimum position change (points) to trigger re-registration |
|
|
151
|
+
| `AUTO_START_DEBOUNCE_MS` | `DapProvider.tsx` | `300` | Debounce delay (ms) before auto-starting a tour |
|
|
152
|
+
|
|
153
|
+
### 3. Programmatic Control
|
|
154
|
+
|
|
155
|
+
The `useDap()` hook exposes full control over tours:
|
|
156
|
+
|
|
157
|
+
```tsx
|
|
158
|
+
const { startTour, stopTour, nextStep, prevStep, activeTour, currentStepIndex } = useDap();
|
|
159
|
+
|
|
160
|
+
// Start a specific tour
|
|
161
|
+
startTour('welcome-tour');
|
|
162
|
+
|
|
163
|
+
// Stop and mark as seen (default)
|
|
164
|
+
stopTour();
|
|
165
|
+
|
|
166
|
+
// Stop WITHOUT marking as seen (user can see it again)
|
|
167
|
+
stopTour(false);
|
|
168
|
+
|
|
169
|
+
// Navigate between steps
|
|
170
|
+
nextStep();
|
|
171
|
+
prevStep();
|
|
172
|
+
```
|
|
173
|
+
|
|
121
174
|
## API Reference
|
|
122
175
|
|
|
123
176
|
### Tour Object
|
|
@@ -128,8 +181,38 @@ const myStorage = {
|
|
|
128
181
|
| `steps` | `TourStep[]` | The sequence of highlighted elements and tooltips. |
|
|
129
182
|
|
|
130
183
|
### TourStep Object
|
|
184
|
+
| Property | Type | Default | Description |
|
|
185
|
+
|-----------|------|---------|-------------|
|
|
186
|
+
| `targetId` | `string` | — | Must directly match the `name=""` prop passed to `<DapTarget>`. |
|
|
187
|
+
| `title` | `string` | — | Large text inside the tooltip. |
|
|
188
|
+
| `description` | `string` | — | Context explanation inside the tooltip. |
|
|
189
|
+
| `position` | `'top' \| 'bottom' \| 'left' \| 'right'` | `'bottom'` | Where the tooltip appears relative to the highlighted target. |
|
|
190
|
+
|
|
191
|
+
### DapTarget Props
|
|
192
|
+
| Property | Type | Description |
|
|
193
|
+
|-----------|------|-------------|
|
|
194
|
+
| `name` | `string` | Unique identifier that matches a `targetId` in a tour step. |
|
|
195
|
+
| `children` | `ReactElement` | The UI element to wrap and highlight. |
|
|
196
|
+
| `...props` | `ViewProps` | All standard React Native `View` props are forwarded. |
|
|
197
|
+
|
|
198
|
+
### useDap() Hook
|
|
131
199
|
| Property | Type | Description |
|
|
132
200
|
|-----------|------|-------------|
|
|
133
|
-
| `
|
|
134
|
-
| `
|
|
135
|
-
| `
|
|
201
|
+
| `startTour(id)` | `(tourId: string) => void` | Start a tour by its ID. |
|
|
202
|
+
| `stopTour(markAsSeen?)` | `(markAsSeen?: boolean) => void` | Stop the active tour. Pass `false` to allow the tour to show again. |
|
|
203
|
+
| `nextStep()` | `() => void` | Advance to the next step. Finishes the tour if on the last step. |
|
|
204
|
+
| `prevStep()` | `() => void` | Go back to the previous step. |
|
|
205
|
+
| `activeTour` | `Tour \| null` | The currently active tour object, or `null`. |
|
|
206
|
+
| `currentStepIndex` | `number` | Index of the current step in the active tour. |
|
|
207
|
+
| `targets` | `Record<string, TargetMeasurement>` | All registered target measurements. |
|
|
208
|
+
| `seenTours` | `Record<string, boolean>` | Map of tour IDs to whether they've been seen. |
|
|
209
|
+
|
|
210
|
+
### StorageAdapter Interface
|
|
211
|
+
| Method | Type | Description |
|
|
212
|
+
|--------|------|-------------|
|
|
213
|
+
| `getItem` | `(key: string) => Promise<string \| null> \| string \| null` | Retrieve a stored value by key. |
|
|
214
|
+
| `setItem` | `(key: string, value: string) => Promise<void> \| void` | Store a value by key. |
|
|
215
|
+
|
|
216
|
+
Compatible with `AsyncStorage`, `MMKV`, or any key-value store that implements this interface.
|
|
217
|
+
# rn-smart-tour
|
|
218
|
+
|
package/dist/DapOverlay.js
CHANGED
|
@@ -37,28 +37,73 @@ exports.DapOverlay = void 0;
|
|
|
37
37
|
const react_1 = __importStar(require("react"));
|
|
38
38
|
const react_native_1 = require("react-native");
|
|
39
39
|
const DapContext_1 = require("./DapContext");
|
|
40
|
-
|
|
40
|
+
/** Gap (pts) between the target highlight and the tooltip card. */
|
|
41
|
+
const TOOLTIP_GAP = 10;
|
|
42
|
+
/** Max width of the tooltip card. */
|
|
43
|
+
const TOOLTIP_WIDTH = 280;
|
|
44
|
+
/** General padding from screen edges. */
|
|
45
|
+
const EDGE_PADDING = 16;
|
|
46
|
+
/** Rough estimated tooltip height — used for bottom-overflow checks. */
|
|
47
|
+
const ESTIMATED_TOOLTIP_HEIGHT = 150;
|
|
48
|
+
/** Rough vertical offset for left/right centering. */
|
|
49
|
+
const VERTICAL_CENTER_OFFSET = 60;
|
|
50
|
+
/** Min screen space reserved at the bottom when centering vertically. */
|
|
51
|
+
const VERTICAL_SAFE_BOTTOM = 120;
|
|
41
52
|
const DapOverlay = () => {
|
|
42
53
|
const context = (0, react_1.useContext)(DapContext_1.DapContext);
|
|
54
|
+
const { width: screenWidth, height: screenHeight } = (0, react_native_1.useWindowDimensions)();
|
|
43
55
|
if (!context || !context.activeTour)
|
|
44
56
|
return null;
|
|
45
|
-
const { activeTour, currentStepIndex, targets, nextStep, stopTour } = context;
|
|
57
|
+
const { activeTour, currentStepIndex, targets, nextStep, prevStep, stopTour } = context;
|
|
46
58
|
const currentStep = activeTour.steps[currentStepIndex];
|
|
47
59
|
if (!currentStep)
|
|
48
60
|
return null;
|
|
49
61
|
const targetId = currentStep.targetId;
|
|
50
62
|
const measurement = targets[targetId];
|
|
51
|
-
// If the target hasn't been measured yet,
|
|
52
|
-
if (!measurement)
|
|
53
|
-
return
|
|
54
|
-
<react_native_1.View style={styles.fullscreen}>
|
|
55
|
-
<react_native_1.Text style={{ color: 'white', marginTop: 100, textAlign: 'center' }}>
|
|
56
|
-
Waiting for target "{targetId}" to mount...
|
|
57
|
-
</react_native_1.Text>
|
|
58
|
-
</react_native_1.View>
|
|
59
|
-
</react_native_1.Modal>);
|
|
60
|
-
}
|
|
63
|
+
// If the target hasn't been measured yet, silently wait — don't show debug text in production.
|
|
64
|
+
if (!measurement)
|
|
65
|
+
return null;
|
|
61
66
|
const { x, y, width, height } = measurement;
|
|
67
|
+
const pos = currentStep.position || 'bottom';
|
|
68
|
+
const tooltipStyle = {
|
|
69
|
+
position: 'absolute',
|
|
70
|
+
width: TOOLTIP_WIDTH,
|
|
71
|
+
backgroundColor: 'white',
|
|
72
|
+
padding: EDGE_PADDING,
|
|
73
|
+
borderRadius: 8,
|
|
74
|
+
shadowColor: '#000',
|
|
75
|
+
shadowOffset: { width: 0, height: 2 },
|
|
76
|
+
shadowOpacity: 0.25,
|
|
77
|
+
shadowRadius: 3.84,
|
|
78
|
+
elevation: 5,
|
|
79
|
+
// Horizontal centering by default
|
|
80
|
+
left: Math.max(EDGE_PADDING, Math.min(x + width / 2 - TOOLTIP_WIDTH / 2, screenWidth - TOOLTIP_WIDTH - EDGE_PADDING)),
|
|
81
|
+
};
|
|
82
|
+
if (pos === 'top') {
|
|
83
|
+
tooltipStyle.bottom = screenHeight - y + TOOLTIP_GAP;
|
|
84
|
+
}
|
|
85
|
+
else if (pos === 'left') {
|
|
86
|
+
tooltipStyle.right = screenWidth - x + TOOLTIP_GAP;
|
|
87
|
+
tooltipStyle.left = undefined; // Clear the default left
|
|
88
|
+
tooltipStyle.width = Math.min(TOOLTIP_WIDTH, x - EDGE_PADDING * 2);
|
|
89
|
+
tooltipStyle.top = Math.max(EDGE_PADDING, Math.min(y + height / 2 - VERTICAL_CENTER_OFFSET, screenHeight - VERTICAL_SAFE_BOTTOM));
|
|
90
|
+
}
|
|
91
|
+
else if (pos === 'right') {
|
|
92
|
+
tooltipStyle.left = x + width + TOOLTIP_GAP;
|
|
93
|
+
tooltipStyle.width = Math.min(TOOLTIP_WIDTH, screenWidth - (x + width) - EDGE_PADDING * 2);
|
|
94
|
+
tooltipStyle.top = Math.max(EDGE_PADDING, Math.min(y + height / 2 - VERTICAL_CENTER_OFFSET, screenHeight - VERTICAL_SAFE_BOTTOM));
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
// bottom (default)
|
|
98
|
+
tooltipStyle.top = y + height + TOOLTIP_GAP;
|
|
99
|
+
// Check if it spills off bottom of screen
|
|
100
|
+
if (tooltipStyle.top + ESTIMATED_TOOLTIP_HEIGHT > screenHeight) {
|
|
101
|
+
tooltipStyle.top = undefined;
|
|
102
|
+
tooltipStyle.bottom = EDGE_PADDING; // Pin to bottom if it overflows
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const isFirstStep = currentStepIndex === 0;
|
|
106
|
+
const isLastStep = currentStepIndex === activeTour.steps.length - 1;
|
|
62
107
|
return (<react_native_1.Modal transparent visible animationType="fade">
|
|
63
108
|
<react_native_1.View style={styles.fullscreen}>
|
|
64
109
|
{/* Top backdrop */}
|
|
@@ -71,16 +116,27 @@ const DapOverlay = () => {
|
|
|
71
116
|
<react_native_1.View style={[styles.backdrop, { top: y, left: x + width, right: 0, height }]}/>
|
|
72
117
|
|
|
73
118
|
{/* The Tooltip Card */}
|
|
74
|
-
<react_native_1.View style={
|
|
119
|
+
<react_native_1.View style={tooltipStyle}>
|
|
75
120
|
<react_native_1.Text style={styles.title}>{currentStep.title}</react_native_1.Text>
|
|
76
121
|
<react_native_1.Text style={styles.description}>{currentStep.description}</react_native_1.Text>
|
|
122
|
+
|
|
123
|
+
{/* Step indicator */}
|
|
124
|
+
{activeTour.steps.length > 1 && (<react_native_1.Text style={styles.stepIndicator}>
|
|
125
|
+
{currentStepIndex + 1} / {activeTour.steps.length}
|
|
126
|
+
</react_native_1.Text>)}
|
|
127
|
+
|
|
77
128
|
<react_native_1.View style={styles.actions}>
|
|
78
|
-
<react_native_1.TouchableOpacity onPress={stopTour} style={styles.actionBtn}>
|
|
129
|
+
<react_native_1.TouchableOpacity onPress={() => stopTour()} style={styles.actionBtn}>
|
|
79
130
|
<react_native_1.Text style={styles.actionText}>Skip</react_native_1.Text>
|
|
80
131
|
</react_native_1.TouchableOpacity>
|
|
132
|
+
|
|
133
|
+
{!isFirstStep && (<react_native_1.TouchableOpacity onPress={prevStep} style={styles.actionBtn}>
|
|
134
|
+
<react_native_1.Text style={styles.actionText}>Back</react_native_1.Text>
|
|
135
|
+
</react_native_1.TouchableOpacity>)}
|
|
136
|
+
|
|
81
137
|
<react_native_1.TouchableOpacity onPress={nextStep} style={[styles.actionBtn, styles.primaryBtn]}>
|
|
82
138
|
<react_native_1.Text style={[styles.actionText, styles.primaryText]}>
|
|
83
|
-
{
|
|
139
|
+
{isLastStep ? 'Finish' : 'Next'}
|
|
84
140
|
</react_native_1.Text>
|
|
85
141
|
</react_native_1.TouchableOpacity>
|
|
86
142
|
</react_native_1.View>
|
|
@@ -97,18 +153,6 @@ const styles = react_native_1.StyleSheet.create({
|
|
|
97
153
|
position: 'absolute',
|
|
98
154
|
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
|
99
155
|
},
|
|
100
|
-
tooltipContainer: {
|
|
101
|
-
position: 'absolute',
|
|
102
|
-
backgroundColor: 'white',
|
|
103
|
-
padding: 16,
|
|
104
|
-
borderRadius: 8,
|
|
105
|
-
width: 250,
|
|
106
|
-
shadowColor: '#000',
|
|
107
|
-
shadowOffset: { width: 0, height: 2 },
|
|
108
|
-
shadowOpacity: 0.25,
|
|
109
|
-
shadowRadius: 3.84,
|
|
110
|
-
elevation: 5,
|
|
111
|
-
},
|
|
112
156
|
title: {
|
|
113
157
|
fontSize: 16,
|
|
114
158
|
fontWeight: 'bold',
|
|
@@ -118,7 +162,13 @@ const styles = react_native_1.StyleSheet.create({
|
|
|
118
162
|
description: {
|
|
119
163
|
fontSize: 14,
|
|
120
164
|
color: '#666',
|
|
121
|
-
marginBottom:
|
|
165
|
+
marginBottom: 8,
|
|
166
|
+
},
|
|
167
|
+
stepIndicator: {
|
|
168
|
+
fontSize: 12,
|
|
169
|
+
color: '#999',
|
|
170
|
+
textAlign: 'right',
|
|
171
|
+
marginBottom: 8,
|
|
122
172
|
},
|
|
123
173
|
actions: {
|
|
124
174
|
flexDirection: 'row',
|
package/dist/DapProvider.js
CHANGED
|
@@ -38,12 +38,21 @@ const react_1 = __importStar(require("react"));
|
|
|
38
38
|
const DapContext_1 = require("./DapContext");
|
|
39
39
|
const DapOverlay_1 = require("./DapOverlay");
|
|
40
40
|
const STORAGE_KEY = '@rn-dap:seen_tours';
|
|
41
|
+
/**
|
|
42
|
+
* Debounce delay (ms) before auto-starting a tour after a target registers.
|
|
43
|
+
* This gives the multi-pass measurement in DapTarget time to settle on the
|
|
44
|
+
* final coordinates before the overlay is shown.
|
|
45
|
+
*/
|
|
46
|
+
const AUTO_START_DEBOUNCE_MS = 300;
|
|
41
47
|
const DapProvider = ({ children, tours, storageAdapter }) => {
|
|
42
48
|
const [targets, setTargets] = (0, react_1.useState)({});
|
|
43
49
|
const [activeTourId, setActiveTourId] = (0, react_1.useState)(null);
|
|
44
50
|
const [currentStepIndex, setCurrentStepIndex] = (0, react_1.useState)(0);
|
|
45
51
|
const [seenTours, setSeenTours] = (0, react_1.useState)({});
|
|
46
52
|
const [isStorageLoaded, setIsStorageLoaded] = (0, react_1.useState)(!storageAdapter);
|
|
53
|
+
// Ref to avoid stale closures — always holds the latest activeTourId.
|
|
54
|
+
const activeTourIdRef = (0, react_1.useRef)(activeTourId);
|
|
55
|
+
activeTourIdRef.current = activeTourId;
|
|
47
56
|
// Load seen tours on mount if a storage adapter is provided
|
|
48
57
|
(0, react_1.useEffect)(() => {
|
|
49
58
|
const loadStorage = async () => {
|
|
@@ -62,18 +71,18 @@ const DapProvider = ({ children, tours, storageAdapter }) => {
|
|
|
62
71
|
};
|
|
63
72
|
loadStorage();
|
|
64
73
|
}, [storageAdapter]);
|
|
65
|
-
const saveSeenTour = async (tourId) => {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
console.error('[rn-dap] failed to save storage', e);
|
|
74
|
+
const saveSeenTour = (0, react_1.useCallback)(async (tourId) => {
|
|
75
|
+
setSeenTours(prev => {
|
|
76
|
+
const nextSeen = { ...prev, [tourId]: true };
|
|
77
|
+
if (storageAdapter) {
|
|
78
|
+
// Safe side-effect here for async storage, as long as it's fire-and-forget
|
|
79
|
+
Promise.resolve(storageAdapter.setItem(STORAGE_KEY, JSON.stringify(nextSeen))).catch(e => {
|
|
80
|
+
console.error('[rn-dap] failed to save storage', e);
|
|
81
|
+
});
|
|
74
82
|
}
|
|
75
|
-
|
|
76
|
-
|
|
83
|
+
return nextSeen;
|
|
84
|
+
});
|
|
85
|
+
}, [storageAdapter]);
|
|
77
86
|
const registerTarget = (0, react_1.useCallback)((id, measurement) => {
|
|
78
87
|
setTargets(prev => ({ ...prev, [id]: measurement }));
|
|
79
88
|
}, []);
|
|
@@ -93,13 +102,15 @@ const DapProvider = ({ children, tours, storageAdapter }) => {
|
|
|
93
102
|
console.warn(`[rn-dap] Tour with id ${tourId} not found.`);
|
|
94
103
|
}
|
|
95
104
|
}, [tours, seenTours]);
|
|
105
|
+
// Uses activeTourIdRef to avoid stale closures and prevent cascading re-renders.
|
|
96
106
|
const stopTour = (0, react_1.useCallback)((markAsSeen = true) => {
|
|
97
|
-
|
|
98
|
-
|
|
107
|
+
const currentTourId = activeTourIdRef.current;
|
|
108
|
+
if (currentTourId && markAsSeen) {
|
|
109
|
+
saveSeenTour(currentTourId);
|
|
99
110
|
}
|
|
100
111
|
setActiveTourId(null);
|
|
101
112
|
setCurrentStepIndex(0);
|
|
102
|
-
}, [
|
|
113
|
+
}, [saveSeenTour]);
|
|
103
114
|
const nextStep = (0, react_1.useCallback)(() => {
|
|
104
115
|
if (activeTourId && tours[activeTourId]) {
|
|
105
116
|
const tour = tours[activeTourId];
|
|
@@ -117,36 +128,61 @@ const DapProvider = ({ children, tours, storageAdapter }) => {
|
|
|
117
128
|
setCurrentStepIndex(prev => prev - 1);
|
|
118
129
|
}
|
|
119
130
|
}, [currentStepIndex]);
|
|
120
|
-
|
|
131
|
+
/** Timer ref for debounced auto-start. */
|
|
132
|
+
const autoStartTimerRef = (0, react_1.useRef)();
|
|
133
|
+
// Auto-Start Engine — debounced so multi-pass measurements can settle.
|
|
121
134
|
(0, react_1.useEffect)(() => {
|
|
122
135
|
// Wait until storage is loaded, and ensure no tour is currently running
|
|
123
136
|
if (!isStorageLoaded || activeTourId)
|
|
124
137
|
return;
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
138
|
+
// Clear any previously scheduled auto-start
|
|
139
|
+
if (autoStartTimerRef.current) {
|
|
140
|
+
clearTimeout(autoStartTimerRef.current);
|
|
141
|
+
}
|
|
142
|
+
autoStartTimerRef.current = setTimeout(() => {
|
|
143
|
+
for (const tourId of Object.keys(tours)) {
|
|
144
|
+
const tour = tours[tourId];
|
|
145
|
+
if (tour.autoStart && !seenTours[tourId] && tour.steps.length > 0) {
|
|
146
|
+
const firstTargetId = tour.steps[0].targetId;
|
|
147
|
+
// If the first target of an unread, auto-starting tour is mounted
|
|
148
|
+
if (targets[firstTargetId]) {
|
|
149
|
+
startTour(tourId);
|
|
150
|
+
break; // Start only one auto-tour at a time
|
|
151
|
+
}
|
|
133
152
|
}
|
|
134
153
|
}
|
|
135
|
-
}
|
|
154
|
+
}, AUTO_START_DEBOUNCE_MS);
|
|
155
|
+
return () => {
|
|
156
|
+
if (autoStartTimerRef.current) {
|
|
157
|
+
clearTimeout(autoStartTimerRef.current);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
136
160
|
}, [targets, tours, seenTours, isStorageLoaded, activeTourId, startTour]);
|
|
137
161
|
const activeTour = activeTourId ? tours[activeTourId] : null;
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
162
|
+
const contextValue = (0, react_1.useMemo)(() => ({
|
|
163
|
+
registerTarget,
|
|
164
|
+
unregisterTarget,
|
|
165
|
+
startTour,
|
|
166
|
+
stopTour,
|
|
167
|
+
nextStep,
|
|
168
|
+
prevStep,
|
|
169
|
+
activeTour,
|
|
170
|
+
currentStepIndex,
|
|
171
|
+
targets,
|
|
172
|
+
seenTours
|
|
173
|
+
}), [
|
|
174
|
+
registerTarget,
|
|
175
|
+
unregisterTarget,
|
|
176
|
+
startTour,
|
|
177
|
+
stopTour,
|
|
178
|
+
nextStep,
|
|
179
|
+
prevStep,
|
|
180
|
+
activeTour,
|
|
181
|
+
currentStepIndex,
|
|
182
|
+
targets,
|
|
183
|
+
seenTours
|
|
184
|
+
]);
|
|
185
|
+
return (<DapContext_1.DapContext.Provider value={contextValue}>
|
|
150
186
|
{children}
|
|
151
187
|
<DapOverlay_1.DapOverlay />
|
|
152
188
|
</DapContext_1.DapContext.Provider>);
|
package/dist/DapTarget.js
CHANGED
|
@@ -37,31 +37,85 @@ exports.DapTarget = void 0;
|
|
|
37
37
|
const react_1 = __importStar(require("react"));
|
|
38
38
|
const react_native_1 = require("react-native");
|
|
39
39
|
const DapContext_1 = require("./DapContext");
|
|
40
|
+
/**
|
|
41
|
+
* Multi-pass measurement delays (ms).
|
|
42
|
+
* The first pass captures an early estimate; subsequent passes self-correct
|
|
43
|
+
* after screen-transition animations and async layout shifts have settled.
|
|
44
|
+
*/
|
|
45
|
+
const MEASUREMENT_DELAYS = [100, 500, 1000];
|
|
46
|
+
/** Threshold in points — ignore sub-pixel drift to avoid unnecessary re-registers. */
|
|
47
|
+
const POSITION_THRESHOLD = 1;
|
|
40
48
|
const DapTarget = ({ name, children, ...props }) => {
|
|
41
49
|
const viewRef = (0, react_1.useRef)(null);
|
|
42
50
|
const context = (0, react_1.useContext)(DapContext_1.DapContext);
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
51
|
+
/** Holds all scheduled timer IDs so we can cancel them on re-layout or unmount. */
|
|
52
|
+
const timersRef = (0, react_1.useRef)([]);
|
|
53
|
+
/** Last successfully registered measurement — used for deduplication. */
|
|
54
|
+
const lastMeasurementRef = (0, react_1.useRef)(null);
|
|
55
|
+
/** Clear every pending measurement timer. */
|
|
56
|
+
const clearAllTimers = (0, react_1.useCallback)(() => {
|
|
57
|
+
timersRef.current.forEach(clearTimeout);
|
|
58
|
+
timersRef.current = [];
|
|
59
|
+
}, []);
|
|
60
|
+
/** Returns true if the new measurement differs meaningfully from the previous one. */
|
|
61
|
+
const hasPositionChanged = (0, react_1.useCallback)((prev, next) => {
|
|
62
|
+
if (!prev)
|
|
63
|
+
return true; // First measurement — always register.
|
|
64
|
+
return (Math.abs(prev.x - next.x) > POSITION_THRESHOLD ||
|
|
65
|
+
Math.abs(prev.y - next.y) > POSITION_THRESHOLD ||
|
|
66
|
+
Math.abs(prev.width - next.width) > POSITION_THRESHOLD ||
|
|
67
|
+
Math.abs(prev.height - next.height) > POSITION_THRESHOLD);
|
|
68
|
+
}, []);
|
|
69
|
+
/**
|
|
70
|
+
* Schedule measureInWindow calls at each delay.
|
|
71
|
+
* Every invocation first cancels any previously scheduled timers so that
|
|
72
|
+
* rapid-fire onLayout events don't pile up stale measurements.
|
|
73
|
+
*/
|
|
74
|
+
const measureAndRegister = (0, react_1.useCallback)(() => {
|
|
75
|
+
clearAllTimers();
|
|
76
|
+
if (!viewRef.current || !context)
|
|
77
|
+
return;
|
|
78
|
+
const scheduleAtDelay = (delay) => {
|
|
79
|
+
const id = setTimeout(() => {
|
|
80
|
+
if (!viewRef.current)
|
|
81
|
+
return;
|
|
82
|
+
viewRef.current.measureInWindow((x, y, width, height) => {
|
|
83
|
+
if (width <= 0 || height <= 0)
|
|
84
|
+
return;
|
|
85
|
+
const next = { x, y, width, height };
|
|
86
|
+
if (hasPositionChanged(lastMeasurementRef.current, next)) {
|
|
87
|
+
lastMeasurementRef.current = next;
|
|
88
|
+
context.registerTarget(name, next);
|
|
49
89
|
}
|
|
50
90
|
});
|
|
51
|
-
},
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
91
|
+
}, delay);
|
|
92
|
+
timersRef.current.push(id);
|
|
93
|
+
};
|
|
94
|
+
MEASUREMENT_DELAYS.forEach(scheduleAtDelay);
|
|
95
|
+
}, [name, context, clearAllTimers, hasPositionChanged]);
|
|
96
|
+
const handleLayout = (0, react_1.useCallback)((e) => {
|
|
55
97
|
measureAndRegister();
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
98
|
+
props.onLayout?.(e);
|
|
99
|
+
}, [measureAndRegister, props.onLayout]);
|
|
100
|
+
const unregisterTarget = context?.unregisterTarget;
|
|
101
|
+
// Re-measure when the screen dimensions change (rotation, split-screen, foldables).
|
|
102
|
+
(0, react_1.useEffect)(() => {
|
|
103
|
+
const subscription = react_native_1.Dimensions.addEventListener('change', () => {
|
|
104
|
+
// Reset the last measurement so the next pass always registers.
|
|
105
|
+
lastMeasurementRef.current = null;
|
|
106
|
+
measureAndRegister();
|
|
107
|
+
});
|
|
108
|
+
return () => {
|
|
109
|
+
subscription.remove();
|
|
110
|
+
};
|
|
111
|
+
}, [measureAndRegister]);
|
|
112
|
+
// Cleanup on unmount or when name changes.
|
|
60
113
|
(0, react_1.useEffect)(() => {
|
|
61
114
|
return () => {
|
|
62
|
-
|
|
115
|
+
clearAllTimers();
|
|
116
|
+
unregisterTarget?.(name);
|
|
63
117
|
};
|
|
64
|
-
}, [name,
|
|
118
|
+
}, [name, unregisterTarget, clearAllTimers]);
|
|
65
119
|
// collapsable={false} is vital for Android, otherwise it gets optimized away and measure fails
|
|
66
120
|
return (<react_native_1.View ref={viewRef} onLayout={handleLayout} collapsable={false} {...props}>
|
|
67
121
|
{children}
|
package/dist/types.d.ts
CHANGED
|
@@ -25,7 +25,7 @@ export interface DapContextType {
|
|
|
25
25
|
startTour: (tourId: string) => void;
|
|
26
26
|
nextStep: () => void;
|
|
27
27
|
prevStep: () => void;
|
|
28
|
-
stopTour: () => void;
|
|
28
|
+
stopTour: (markAsSeen?: boolean) => void;
|
|
29
29
|
activeTour: Tour | null;
|
|
30
30
|
currentStepIndex: number;
|
|
31
31
|
targets: Record<string, TargetMeasurement>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rn-smart-tour",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "Enterprise-grade Digital Adoption Platform (DAP) package for React Native. Provides guided walkthroughs, tooltips, and app tours.",
|
|
5
5
|
"author": "Vishwas Gaur",
|
|
6
6
|
"license": "MIT",
|
|
@@ -9,6 +9,14 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"dist"
|
|
11
11
|
],
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "https://github.com/Vishwasgaur0819/rn-smart-tour"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://github.com/Vishwasgaur0819/rn-smart-tour#readme",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/Vishwasgaur0819/rn-smart-tour/issues"
|
|
19
|
+
},
|
|
12
20
|
"keywords": [
|
|
13
21
|
"react-native",
|
|
14
22
|
"tour",
|
|
@@ -16,20 +24,24 @@
|
|
|
16
24
|
"onboarding",
|
|
17
25
|
"dap",
|
|
18
26
|
"guide",
|
|
19
|
-
"product-tour"
|
|
27
|
+
"product-tour",
|
|
28
|
+
"tooltip",
|
|
29
|
+
"overlay",
|
|
30
|
+
"digital-adoption"
|
|
20
31
|
],
|
|
21
32
|
"scripts": {
|
|
22
|
-
"build": "tsc"
|
|
33
|
+
"build": "tsc",
|
|
34
|
+
"prepublishOnly": "npm run build"
|
|
23
35
|
},
|
|
24
36
|
"peerDependencies": {
|
|
25
|
-
"react": "
|
|
26
|
-
"react-native": "
|
|
37
|
+
"react": ">=16.8.0",
|
|
38
|
+
"react-native": ">=0.63.0"
|
|
27
39
|
},
|
|
28
40
|
"devDependencies": {
|
|
29
|
-
"@types/react": "^18.
|
|
41
|
+
"@types/react": "^18.2.0",
|
|
30
42
|
"@types/react-native": "^0.72.8",
|
|
31
|
-
"react": "
|
|
32
|
-
"react-native": "
|
|
43
|
+
"react": "18.2.0",
|
|
44
|
+
"react-native": "0.73.11",
|
|
33
45
|
"typescript": "^5.9.3"
|
|
34
46
|
}
|
|
35
47
|
}
|