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 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
- - **Zero Config Measurement**: Wraps your components and seamlessly uses Native `measureInWindow` API.
7
- - **Auto-Start Engine**: Automatically triggers tours the moment specific features mount.
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 'react-native-app-tour';
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
- | `targetId` | `string` | Must directly match the `name=""` prop passed to `<DapTarget>`. |
134
- | `title` | `string` | Large text inside the tooltip. |
135
- | `description` | `string` | Context explanation inside the tooltip. |
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
+
@@ -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
- const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = react_native_1.Dimensions.get('window');
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, we can't show the overlay correctly.
52
- if (!measurement) {
53
- return (<react_native_1.Modal transparent visible animationType="fade">
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={[styles.tooltipContainer, { top: y + height + 10, left: Math.max(10, x - 20) }]}>
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
- {currentStepIndex === activeTour.steps.length - 1 ? 'Finish' : 'Next'}
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: 16,
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',
@@ -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
- const nextSeen = { ...seenTours, [tourId]: true };
67
- setSeenTours(nextSeen);
68
- if (storageAdapter) {
69
- try {
70
- await storageAdapter.setItem(STORAGE_KEY, JSON.stringify(nextSeen));
71
- }
72
- catch (e) {
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
- if (activeTourId && markAsSeen) {
98
- saveSeenTour(activeTourId);
107
+ const currentTourId = activeTourIdRef.current;
108
+ if (currentTourId && markAsSeen) {
109
+ saveSeenTour(currentTourId);
99
110
  }
100
111
  setActiveTourId(null);
101
112
  setCurrentStepIndex(0);
102
- }, [activeTourId, seenTours]);
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
- // Auto-Start Engine
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
- for (const tourId of Object.keys(tours)) {
126
- const tour = tours[tourId];
127
- if (tour.autoStart && !seenTours[tourId] && tour.steps.length > 0) {
128
- const firstTargetId = tour.steps[0].targetId;
129
- // If the first target of an unread, auto-starting tour is mounted
130
- if (targets[firstTargetId]) {
131
- startTour(tourId);
132
- break; // Start only one auto-tour at a time
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
- return (<DapContext_1.DapContext.Provider value={{
139
- registerTarget,
140
- unregisterTarget,
141
- startTour,
142
- stopTour,
143
- nextStep,
144
- prevStep,
145
- activeTour,
146
- currentStepIndex,
147
- targets,
148
- seenTours
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
- const measureAndRegister = () => {
44
- if (viewRef.current && context) {
45
- setTimeout(() => {
46
- viewRef.current?.measureInWindow((x, y, width, height) => {
47
- if (width > 0 && height > 0) {
48
- context.registerTarget(name, { x, y, width, height });
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
- }, 100);
52
- }
53
- };
54
- const handleLayout = (e) => {
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
- if (props.onLayout) {
57
- props.onLayout(e);
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
- context?.unregisterTarget(name);
115
+ clearAllTimers();
116
+ unregisterTarget?.(name);
63
117
  };
64
- }, [name, context]);
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.1",
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.3.28",
41
+ "@types/react": "^18.2.0",
30
42
  "@types/react-native": "^0.72.8",
31
- "react": "^19.2.4",
32
- "react-native": "^0.84.1",
43
+ "react": "18.2.0",
44
+ "react-native": "0.73.11",
33
45
  "typescript": "^5.9.3"
34
46
  }
35
47
  }