@buoy-gg/core 1.7.8 → 2.1.2
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/lib/commonjs/Buoy.js +104 -0
- package/lib/commonjs/floatingMenu/AppHost.js +2 -2
- package/lib/commonjs/floatingMenu/DevToolsSettingsModal.js +18 -279
- package/lib/commonjs/floatingMenu/FloatingDevTools.js +8 -1
- package/lib/commonjs/floatingMenu/FloatingDevTools.web.js +1 -2
- package/lib/commonjs/floatingMenu/FloatingMenu.js +4 -4
- package/lib/commonjs/floatingMenu/MinimizedToolsContext.js +2 -2
- package/lib/commonjs/floatingMenu/autoDiscoverPresets.js +15 -0
- package/lib/commonjs/floatingMenu/dial/DialDevTools.js +4 -4
- package/lib/commonjs/floatingMenu/floatingTools.js +268 -74
- package/lib/commonjs/index.js +24 -8
- package/lib/module/Buoy.js +100 -0
- package/lib/module/floatingMenu/AppHost.js +3 -3
- package/lib/module/floatingMenu/DevToolsSettingsModal.js +20 -281
- package/lib/module/floatingMenu/FloatingDevTools.js +8 -1
- package/lib/module/floatingMenu/FloatingDevTools.web.js +1 -2
- package/lib/module/floatingMenu/FloatingMenu.js +5 -5
- package/lib/module/floatingMenu/MinimizedToolsContext.js +3 -3
- package/lib/module/floatingMenu/autoDiscoverPresets.js +15 -0
- package/lib/module/floatingMenu/dial/DialDevTools.js +5 -5
- package/lib/module/floatingMenu/floatingTools.js +269 -75
- package/lib/module/index.js +3 -1
- package/lib/typescript/commonjs/Buoy.d.ts +79 -0
- package/lib/typescript/commonjs/Buoy.d.ts.map +1 -0
- package/lib/typescript/commonjs/floatingMenu/DevToolsSettingsModal.d.ts.map +1 -1
- package/lib/typescript/commonjs/floatingMenu/FloatingDevTools.d.ts +25 -5
- package/lib/typescript/commonjs/floatingMenu/FloatingDevTools.d.ts.map +1 -1
- package/lib/typescript/commonjs/floatingMenu/FloatingDevTools.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/floatingMenu/FloatingMenu.d.ts.map +1 -1
- package/lib/typescript/commonjs/floatingMenu/autoDiscoverPresets.d.ts.map +1 -1
- package/lib/typescript/commonjs/floatingMenu/dial/DialDevTools.d.ts.map +1 -1
- package/lib/typescript/commonjs/floatingMenu/floatingTools.d.ts +1 -1
- package/lib/typescript/commonjs/floatingMenu/floatingTools.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +4 -2
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/module/Buoy.d.ts +79 -0
- package/lib/typescript/module/Buoy.d.ts.map +1 -0
- package/lib/typescript/module/floatingMenu/DevToolsSettingsModal.d.ts.map +1 -1
- package/lib/typescript/module/floatingMenu/FloatingDevTools.d.ts +25 -5
- package/lib/typescript/module/floatingMenu/FloatingDevTools.d.ts.map +1 -1
- package/lib/typescript/module/floatingMenu/FloatingDevTools.web.d.ts.map +1 -1
- package/lib/typescript/module/floatingMenu/FloatingMenu.d.ts.map +1 -1
- package/lib/typescript/module/floatingMenu/autoDiscoverPresets.d.ts.map +1 -1
- package/lib/typescript/module/floatingMenu/dial/DialDevTools.d.ts.map +1 -1
- package/lib/typescript/module/floatingMenu/floatingTools.d.ts +1 -1
- package/lib/typescript/module/floatingMenu/floatingTools.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +4 -2
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/package.json +5 -5
|
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|
|
4
4
|
import { Pressable, StyleSheet, View, Dimensions, Text, Animated, Easing } from "react-native";
|
|
5
5
|
// Icons are provided by installedApps; no direct icon imports here.
|
|
6
6
|
import { DialIcon } from "./DialIcon.js";
|
|
7
|
-
import {
|
|
7
|
+
import { persistentStorage, useHintsDisabled, devToolsStorageKeys, buoyColors } from "@buoy-gg/shared-ui";
|
|
8
8
|
import { DevToolsSettingsModal, useDevToolsSettings } from "../DevToolsSettingsModal";
|
|
9
9
|
import { useIsPro } from "@buoy-gg/license";
|
|
10
10
|
import { useAppHost } from "../AppHost.js";
|
|
@@ -55,7 +55,7 @@ export const DialDevTools = ({
|
|
|
55
55
|
useEffect(() => {
|
|
56
56
|
const loadSettingsModalState = async () => {
|
|
57
57
|
try {
|
|
58
|
-
const savedModalOpen = await
|
|
58
|
+
const savedModalOpen = await persistentStorage.getItem(devToolsStorageKeys.settings.modalOpen());
|
|
59
59
|
if (savedModalOpen === "true") {
|
|
60
60
|
setIsSettingsModalOpen(true);
|
|
61
61
|
}
|
|
@@ -72,7 +72,7 @@ export const DialDevTools = ({
|
|
|
72
72
|
useEffect(() => {
|
|
73
73
|
// Only persist after initial state is loaded to avoid overwriting with default
|
|
74
74
|
if (!settingsModalStateLoaded) return;
|
|
75
|
-
|
|
75
|
+
persistentStorage.setItem(devToolsStorageKeys.settings.modalOpen(), isSettingsModalOpen ? "true" : "false").catch(error => {
|
|
76
76
|
// Failed to save settings modal state - continue without persistence
|
|
77
77
|
console.warn("Failed to save settings modal state:", error);
|
|
78
78
|
});
|
|
@@ -107,7 +107,7 @@ export const DialDevTools = ({
|
|
|
107
107
|
}
|
|
108
108
|
const checkOnboarding = async () => {
|
|
109
109
|
try {
|
|
110
|
-
const hasSeenTooltip = await
|
|
110
|
+
const hasSeenTooltip = await persistentStorage.getItem(ONBOARDING_STORAGE_KEY);
|
|
111
111
|
if (!hasSeenTooltip) {
|
|
112
112
|
// Small delay to let the entrance animations play first
|
|
113
113
|
setTimeout(() => {
|
|
@@ -406,7 +406,7 @@ export const DialDevTools = ({
|
|
|
406
406
|
setShowOnboardingTooltip(false);
|
|
407
407
|
|
|
408
408
|
// Save to storage asynchronously in the background
|
|
409
|
-
|
|
409
|
+
persistentStorage.setItem(ONBOARDING_STORAGE_KEY, "true").catch(error => {
|
|
410
410
|
// Silently fail - user already saw onboarding, just won't persist
|
|
411
411
|
console.warn("Failed to save dial onboarding state:", error);
|
|
412
412
|
});
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useMemo, useRef, useState, useContext, createContext, useCallback, Children } from "react";
|
|
4
4
|
import { Animated, Dimensions, View, Text, TouchableOpacity } from "react-native";
|
|
5
|
-
import { getSafeAreaInsets,
|
|
5
|
+
import { getSafeAreaInsets, persistentStorage, buoyColors } from "@buoy-gg/shared-ui";
|
|
6
6
|
import { DraggableHeader } from "./DraggableHeader.js";
|
|
7
7
|
import { useSafeAreaInsets } from "@buoy-gg/shared-ui";
|
|
8
8
|
import { calculateTargetPosition } from "./dial/onboardingConstants.js";
|
|
@@ -81,7 +81,13 @@ const STORAGE_KEYS = {
|
|
|
81
81
|
BUBBLE_POSITION_Y: "@react_buoy_bubble_position_y"
|
|
82
82
|
};
|
|
83
83
|
|
|
84
|
-
//
|
|
84
|
+
// Position constants
|
|
85
|
+
const VISIBLE_HANDLE_WIDTH = 32;
|
|
86
|
+
const EDGE_PADDING = 20;
|
|
87
|
+
const LAYOUT_SETTLE_DELAY_MS = 150;
|
|
88
|
+
|
|
89
|
+
/** Helper to get current value from Animated.Value without triggering updates */
|
|
90
|
+
const getAnimatedValue = value => value.__getValue();
|
|
85
91
|
|
|
86
92
|
// =============================
|
|
87
93
|
// Position persistence hook
|
|
@@ -100,8 +106,9 @@ const STORAGE_KEYS = {
|
|
|
100
106
|
* @param props.enabled - Whether position persistence is enabled
|
|
101
107
|
* @param props.visibleHandleWidth - Width of visible handle when bubble is hidden
|
|
102
108
|
* @param props.listenersSuspended - Pause automatic listeners without disabling manual saves
|
|
109
|
+
* @param props.onPositionLoaded - Callback fired once position is loaded and set
|
|
103
110
|
*
|
|
104
|
-
* @returns Object containing position management functions
|
|
111
|
+
* @returns Object containing position management functions and loading state
|
|
105
112
|
*
|
|
106
113
|
* @performance Uses debounced saving to avoid excessive storage operations
|
|
107
114
|
* @performance Validates positions against screen boundaries and safe areas
|
|
@@ -112,33 +119,66 @@ function useFloatingToolsPosition({
|
|
|
112
119
|
bubbleHeight = 32,
|
|
113
120
|
enabled = true,
|
|
114
121
|
visibleHandleWidth = 32,
|
|
115
|
-
listenersSuspended = false
|
|
122
|
+
listenersSuspended = false,
|
|
123
|
+
onPositionLoaded
|
|
116
124
|
}) {
|
|
117
|
-
|
|
125
|
+
// Use state instead of ref so we can react to changes and prevent races
|
|
126
|
+
const [isInitialized, setIsInitialized] = useState(false);
|
|
118
127
|
const saveTimeoutRef = useRef(undefined);
|
|
128
|
+
// Track if component is mounted to prevent state updates after unmount
|
|
129
|
+
const isMountedRef = useRef(true);
|
|
130
|
+
|
|
131
|
+
// Cleanup on unmount - cancel any pending saves
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
isMountedRef.current = true;
|
|
134
|
+
return () => {
|
|
135
|
+
isMountedRef.current = false;
|
|
136
|
+
// Clear any pending debounced saves on unmount to prevent saving stale/invalid positions
|
|
137
|
+
if (saveTimeoutRef.current) {
|
|
138
|
+
clearTimeout(saveTimeoutRef.current);
|
|
139
|
+
saveTimeoutRef.current = undefined;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
}, []);
|
|
119
143
|
const savePosition = useCallback(async (x, y) => {
|
|
120
144
|
if (!enabled) return;
|
|
145
|
+
// Don't save if component unmounted or position is clearly invalid
|
|
146
|
+
if (!isMountedRef.current) return;
|
|
147
|
+
// Guard against saving invalid positions (e.g., during HMR with stale dimensions)
|
|
148
|
+
const {
|
|
149
|
+
width: screenWidth
|
|
150
|
+
} = Dimensions.get("window");
|
|
151
|
+
if (screenWidth <= 0 || x < 0 || y < 0) return;
|
|
121
152
|
try {
|
|
122
|
-
await Promise.all([
|
|
153
|
+
await Promise.all([persistentStorage.setItem(STORAGE_KEYS.BUBBLE_POSITION_X, x.toString()), persistentStorage.setItem(STORAGE_KEYS.BUBBLE_POSITION_Y, y.toString())]);
|
|
123
154
|
} catch (error) {
|
|
124
155
|
// Failed to save position - continue without persistence
|
|
125
156
|
}
|
|
126
157
|
}, [enabled]);
|
|
127
158
|
const debouncedSavePosition = useCallback((x, y) => {
|
|
159
|
+
// Don't schedule saves if unmounted
|
|
160
|
+
if (!isMountedRef.current) return;
|
|
128
161
|
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
|
129
|
-
saveTimeoutRef.current = setTimeout(() =>
|
|
162
|
+
saveTimeoutRef.current = setTimeout(() => {
|
|
163
|
+
// Double-check mount status before saving
|
|
164
|
+
if (isMountedRef.current) {
|
|
165
|
+
savePosition(x, y);
|
|
166
|
+
}
|
|
167
|
+
}, 500);
|
|
130
168
|
}, [savePosition]);
|
|
131
169
|
const loadPosition = useCallback(async () => {
|
|
132
170
|
if (!enabled) return null;
|
|
133
171
|
try {
|
|
134
|
-
const [xStr, yStr] = await Promise.all([
|
|
172
|
+
const [xStr, yStr] = await Promise.all([persistentStorage.getItem(STORAGE_KEYS.BUBBLE_POSITION_X), persistentStorage.getItem(STORAGE_KEYS.BUBBLE_POSITION_Y)]);
|
|
135
173
|
if (xStr !== null && yStr !== null) {
|
|
136
174
|
const x = parseFloat(xStr);
|
|
137
175
|
const y = parseFloat(yStr);
|
|
138
|
-
if (!Number.isNaN(x) && !Number.isNaN(y))
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
176
|
+
if (!Number.isNaN(x) && !Number.isNaN(y)) {
|
|
177
|
+
return {
|
|
178
|
+
x,
|
|
179
|
+
y
|
|
180
|
+
};
|
|
181
|
+
}
|
|
142
182
|
}
|
|
143
183
|
} catch (error) {
|
|
144
184
|
// Failed to load position - use default
|
|
@@ -151,62 +191,121 @@ function useFloatingToolsPosition({
|
|
|
151
191
|
height: screenHeight
|
|
152
192
|
} = Dimensions.get("window");
|
|
153
193
|
const safeArea = getSafeAreaInsets();
|
|
194
|
+
|
|
195
|
+
// Guard against invalid screen dimensions (can happen during HMR)
|
|
196
|
+
// Return the position as-is but mark as invalid so caller knows not to trust it
|
|
197
|
+
if (screenWidth <= 0 || screenHeight <= 0) {
|
|
198
|
+
return {
|
|
199
|
+
...position,
|
|
200
|
+
isValid: false
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
154
204
|
// Prevent going off left, top, and bottom edges with safe area
|
|
155
205
|
// Allow pushing off-screen to the right so only the grab handle remains visible
|
|
156
|
-
const minX = safeArea.left;
|
|
157
|
-
const maxX = screenWidth - visibleHandleWidth;
|
|
158
|
-
// Add
|
|
159
|
-
const minY = safeArea.top +
|
|
206
|
+
const minX = safeArea.left;
|
|
207
|
+
const maxX = screenWidth - visibleHandleWidth;
|
|
208
|
+
// Add padding below the safe area top to ensure bubble doesn't go behind notch
|
|
209
|
+
const minY = safeArea.top + EDGE_PADDING;
|
|
160
210
|
const maxY = screenHeight - bubbleHeight - safeArea.bottom; // Respect safe area bottom
|
|
161
211
|
const clamped = {
|
|
162
212
|
x: Math.max(minX, Math.min(position.x, maxX)),
|
|
163
|
-
y: Math.max(minY, Math.min(position.y, maxY))
|
|
213
|
+
y: Math.max(minY, Math.min(position.y, maxY)),
|
|
214
|
+
isValid: true
|
|
164
215
|
};
|
|
165
216
|
return clamped;
|
|
166
217
|
}, [visibleHandleWidth, bubbleHeight]);
|
|
167
218
|
useEffect(() => {
|
|
168
|
-
if (!enabled || isInitialized
|
|
219
|
+
if (!enabled || isInitialized) return;
|
|
220
|
+
let cancelled = false;
|
|
169
221
|
const restore = async () => {
|
|
170
222
|
const saved = await loadPosition();
|
|
223
|
+
|
|
224
|
+
// Check if cancelled or unmounted before updating state
|
|
225
|
+
if (cancelled || !isMountedRef.current) return;
|
|
226
|
+
const {
|
|
227
|
+
width: screenWidth,
|
|
228
|
+
height: screenHeight
|
|
229
|
+
} = Dimensions.get("window");
|
|
230
|
+
const safeArea = getSafeAreaInsets();
|
|
231
|
+
|
|
232
|
+
// If dimensions are invalid (HMR scenario), wait and retry
|
|
233
|
+
if (screenWidth <= 0 || screenHeight <= 0) {
|
|
234
|
+
// Schedule a retry after a short delay
|
|
235
|
+
setTimeout(() => {
|
|
236
|
+
if (isMountedRef.current && !cancelled) {
|
|
237
|
+
restore();
|
|
238
|
+
}
|
|
239
|
+
}, 100);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
let finalPosition;
|
|
243
|
+
let wasHidden = false;
|
|
171
244
|
if (saved) {
|
|
172
245
|
const validated = validatePosition(saved);
|
|
246
|
+
|
|
247
|
+
// If validation returned invalid, retry later
|
|
248
|
+
if (!validated.isValid) {
|
|
249
|
+
setTimeout(() => {
|
|
250
|
+
if (isMountedRef.current && !cancelled) {
|
|
251
|
+
restore();
|
|
252
|
+
}
|
|
253
|
+
}, 100);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
173
257
|
// Check if the saved position is out of bounds
|
|
174
258
|
const wasOutOfBounds = Math.abs(saved.x - validated.x) > 5 || Math.abs(saved.y - validated.y) > 5;
|
|
175
259
|
if (wasOutOfBounds) {
|
|
176
260
|
// Save the corrected position
|
|
177
261
|
await savePosition(validated.x, validated.y);
|
|
178
262
|
}
|
|
179
|
-
|
|
263
|
+
finalPosition = {
|
|
264
|
+
x: validated.x,
|
|
265
|
+
y: validated.y
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
// Check if loaded in hidden state (bubble pushed to right edge)
|
|
269
|
+
wasHidden = validated.x >= screenWidth - visibleHandleWidth - 5;
|
|
180
270
|
} else {
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
const defaultY = Math.max(safeArea.top + 20, Math.min(100, screenHeight - bubbleHeight - safeArea.bottom));
|
|
187
|
-
animatedPosition.setValue({
|
|
188
|
-
x: screenWidth - bubbleWidth - 20,
|
|
189
|
-
y: defaultY // Ensure it's within safe area bounds
|
|
190
|
-
});
|
|
271
|
+
const defaultY = Math.max(safeArea.top + EDGE_PADDING, Math.min(100, screenHeight - bubbleHeight - safeArea.bottom));
|
|
272
|
+
finalPosition = {
|
|
273
|
+
x: screenWidth - bubbleWidth - EDGE_PADDING,
|
|
274
|
+
y: defaultY
|
|
275
|
+
};
|
|
191
276
|
}
|
|
192
|
-
|
|
277
|
+
|
|
278
|
+
// Final mount check before updating
|
|
279
|
+
if (cancelled || !isMountedRef.current) return;
|
|
280
|
+
animatedPosition.setValue(finalPosition);
|
|
281
|
+
setIsInitialized(true);
|
|
282
|
+
onPositionLoaded?.(finalPosition, wasHidden);
|
|
193
283
|
};
|
|
194
284
|
restore();
|
|
195
|
-
|
|
285
|
+
return () => {
|
|
286
|
+
cancelled = true;
|
|
287
|
+
};
|
|
288
|
+
}, [enabled, isInitialized, animatedPosition, loadPosition, validatePosition, savePosition, bubbleWidth, bubbleHeight, visibleHandleWidth, onPositionLoaded]);
|
|
289
|
+
|
|
290
|
+
// Listener effect - now properly depends on isInitialized state
|
|
196
291
|
useEffect(() => {
|
|
197
|
-
|
|
292
|
+
// Don't attach listener until position is loaded
|
|
293
|
+
if (!enabled || !isInitialized || listenersSuspended) return;
|
|
198
294
|
const listener = animatedPosition.addListener(value => {
|
|
199
295
|
debouncedSavePosition(value.x, value.y);
|
|
200
296
|
});
|
|
201
297
|
return () => {
|
|
202
298
|
animatedPosition.removeListener(listener);
|
|
203
|
-
|
|
299
|
+
// Clear debounce timer when listener is removed
|
|
300
|
+
if (saveTimeoutRef.current) {
|
|
301
|
+
clearTimeout(saveTimeoutRef.current);
|
|
302
|
+
saveTimeoutRef.current = undefined;
|
|
303
|
+
}
|
|
204
304
|
};
|
|
205
|
-
}, [enabled, listenersSuspended, animatedPosition, debouncedSavePosition]);
|
|
305
|
+
}, [enabled, isInitialized, listenersSuspended, animatedPosition, debouncedSavePosition]);
|
|
206
306
|
return {
|
|
207
307
|
savePosition,
|
|
208
|
-
|
|
209
|
-
isInitialized: isInitialized.current
|
|
308
|
+
isInitialized
|
|
210
309
|
};
|
|
211
310
|
}
|
|
212
311
|
|
|
@@ -368,7 +467,6 @@ export function FloatingTools({
|
|
|
368
467
|
}) {
|
|
369
468
|
// Animated position and drag state
|
|
370
469
|
const animatedPosition = useRef(new Animated.ValueXY()).current;
|
|
371
|
-
const saveTimeoutRef = useRef(null);
|
|
372
470
|
const [isDragging, setIsDragging] = useState(false);
|
|
373
471
|
const [bubbleSize, setBubbleSize] = useState({
|
|
374
472
|
width: 100,
|
|
@@ -387,26 +485,44 @@ export function FloatingTools({
|
|
|
387
485
|
|
|
388
486
|
// Track previous pushToSide value to detect transitions
|
|
389
487
|
const prevPushToSideRef = useRef(pushToSide);
|
|
488
|
+
|
|
489
|
+
// Track previous bubble width for auto-adjustment when tools are added/removed
|
|
490
|
+
const prevBubbleWidthRef = useRef(null);
|
|
491
|
+
|
|
492
|
+
// Track if layout has settled (after initial render cycle completes)
|
|
493
|
+
const [isLayoutSettled, setIsLayoutSettled] = useState(false);
|
|
390
494
|
const safeAreaInsets = useSafeAreaInsets();
|
|
391
495
|
const {
|
|
392
496
|
width: screenWidth,
|
|
393
497
|
height: screenHeight
|
|
394
498
|
} = Dimensions.get("window");
|
|
395
499
|
|
|
500
|
+
// Callback when position is loaded - sets hidden state properly without racing
|
|
501
|
+
const handlePositionLoaded = useCallback((position, wasHidden) => {
|
|
502
|
+
if (wasHidden) {
|
|
503
|
+
setIsHidden(true);
|
|
504
|
+
}
|
|
505
|
+
}, []);
|
|
506
|
+
|
|
396
507
|
// Position persistence (state/IO extracted to hook)
|
|
397
508
|
const {
|
|
398
|
-
savePosition
|
|
509
|
+
savePosition,
|
|
510
|
+
isInitialized: isPositionInitialized
|
|
399
511
|
} = useFloatingToolsPosition({
|
|
400
512
|
animatedPosition,
|
|
401
513
|
bubbleWidth: bubbleSize.width,
|
|
402
514
|
bubbleHeight: bubbleSize.height,
|
|
403
515
|
enabled: enablePositionPersistence,
|
|
404
|
-
visibleHandleWidth:
|
|
405
|
-
listenersSuspended: isDragging
|
|
516
|
+
visibleHandleWidth: VISIBLE_HANDLE_WIDTH,
|
|
517
|
+
listenersSuspended: isDragging,
|
|
518
|
+
onPositionLoaded: handlePositionLoaded
|
|
406
519
|
});
|
|
407
520
|
|
|
408
521
|
// Effect to handle pushToSide prop changes
|
|
409
522
|
useEffect(() => {
|
|
523
|
+
// Don't process pushToSide until position is initialized (when persistence enabled)
|
|
524
|
+
if (enablePositionPersistence && !isPositionInitialized) return;
|
|
525
|
+
|
|
410
526
|
// Reset user override when pushToSide becomes true (dial/modal opens)
|
|
411
527
|
// This allows auto-hide to work after user manually showed the menu
|
|
412
528
|
if (!prevPushToSideRef.current && pushToSide) {
|
|
@@ -421,15 +537,15 @@ export function FloatingTools({
|
|
|
421
537
|
// 4. User hasn't manually restored (userWantsVisibleRef)
|
|
422
538
|
if (pushToSide && !isHidden && !isDragging && !userWantsVisibleRef.current) {
|
|
423
539
|
// Push to side
|
|
424
|
-
const currentX = animatedPosition.x
|
|
425
|
-
const currentY = animatedPosition.y
|
|
540
|
+
const currentX = getAnimatedValue(animatedPosition.x);
|
|
541
|
+
const currentY = getAnimatedValue(animatedPosition.y);
|
|
426
542
|
|
|
427
543
|
// Save current position
|
|
428
544
|
savedPositionRef.current = {
|
|
429
545
|
x: currentX,
|
|
430
546
|
y: currentY
|
|
431
547
|
};
|
|
432
|
-
const hiddenX = screenWidth -
|
|
548
|
+
const hiddenX = screenWidth - VISIBLE_HANDLE_WIDTH;
|
|
433
549
|
isPushedBySideRef.current = true;
|
|
434
550
|
setIsHidden(true);
|
|
435
551
|
Animated.timing(animatedPosition, {
|
|
@@ -464,22 +580,92 @@ export function FloatingTools({
|
|
|
464
580
|
});
|
|
465
581
|
}
|
|
466
582
|
}
|
|
467
|
-
}, [pushToSide, isHidden, isDragging, screenWidth, animatedPosition, savePosition]);
|
|
583
|
+
}, [enablePositionPersistence, isPositionInitialized, pushToSide, isHidden, isDragging, screenWidth, animatedPosition, savePosition]);
|
|
584
|
+
|
|
585
|
+
// Hidden state is now set via onPositionLoaded callback - no racing effect needed
|
|
468
586
|
|
|
469
|
-
//
|
|
587
|
+
// Wait for layout to settle before enabling width tracking
|
|
588
|
+
// This prevents false-positive width changes during initial render when
|
|
589
|
+
// the bubble measures multiple times as children render
|
|
470
590
|
useEffect(() => {
|
|
471
|
-
if (!
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
}
|
|
478
|
-
};
|
|
479
|
-
// Delay check to ensure position is loaded
|
|
480
|
-
const timer = setTimeout(checkHiddenState, 100);
|
|
591
|
+
if (!isPositionInitialized || isLayoutSettled) return;
|
|
592
|
+
const timer = setTimeout(() => {
|
|
593
|
+
// Capture the current width as the baseline AFTER layout has settled
|
|
594
|
+
prevBubbleWidthRef.current = bubbleSize.width;
|
|
595
|
+
setIsLayoutSettled(true);
|
|
596
|
+
}, LAYOUT_SETTLE_DELAY_MS);
|
|
481
597
|
return () => clearTimeout(timer);
|
|
482
|
-
}, [
|
|
598
|
+
}, [isPositionInitialized, isLayoutSettled, bubbleSize.width]);
|
|
599
|
+
|
|
600
|
+
// Auto-adjust position when bubble width changes (tools added/removed)
|
|
601
|
+
// This keeps the bubble's right edge in the same relative position
|
|
602
|
+
useEffect(() => {
|
|
603
|
+
// Skip if position not initialized yet
|
|
604
|
+
if (!isPositionInitialized) return;
|
|
605
|
+
|
|
606
|
+
// Skip until layout has settled (prevents false-positive width changes during initial render)
|
|
607
|
+
if (!isLayoutSettled) return;
|
|
608
|
+
|
|
609
|
+
// Skip if prevBubbleWidthRef hasn't been set yet
|
|
610
|
+
if (prevBubbleWidthRef.current === null) return;
|
|
611
|
+
|
|
612
|
+
// Skip if currently dragging - let drag handle position
|
|
613
|
+
if (isDragging) {
|
|
614
|
+
prevBubbleWidthRef.current = bubbleSize.width;
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
const prevWidth = prevBubbleWidthRef.current;
|
|
618
|
+
const newWidth = bubbleSize.width;
|
|
619
|
+
const widthDelta = newWidth - prevWidth;
|
|
620
|
+
|
|
621
|
+
// Skip if no significant change (avoid jitter)
|
|
622
|
+
if (Math.abs(widthDelta) < 2) {
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Update ref first
|
|
627
|
+
prevBubbleWidthRef.current = newWidth;
|
|
628
|
+
|
|
629
|
+
// If hidden, only adjust the saved position (not the current animated position)
|
|
630
|
+
if (isHidden) {
|
|
631
|
+
if (savedPositionRef.current) {
|
|
632
|
+
const adjustedX = savedPositionRef.current.x - widthDelta;
|
|
633
|
+
// Clamp to valid bounds
|
|
634
|
+
savedPositionRef.current.x = Math.max(safeAreaInsets.left, Math.min(adjustedX, screenWidth - newWidth - EDGE_PADDING));
|
|
635
|
+
}
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Get current position
|
|
640
|
+
const currentX = getAnimatedValue(animatedPosition.x);
|
|
641
|
+
const currentY = getAnimatedValue(animatedPosition.y);
|
|
642
|
+
|
|
643
|
+
// Calculate new X position
|
|
644
|
+
// If bubble grew (positive delta), move left (decrease X)
|
|
645
|
+
// If bubble shrunk (negative delta), move right (increase X)
|
|
646
|
+
const newX = currentX - widthDelta;
|
|
647
|
+
|
|
648
|
+
// Validate new position is within bounds
|
|
649
|
+
const clampedX = Math.max(safeAreaInsets.left, Math.min(newX, screenWidth - VISIBLE_HANDLE_WIDTH));
|
|
650
|
+
|
|
651
|
+
// Only animate if position actually changed
|
|
652
|
+
if (Math.abs(clampedX - currentX) > 1) {
|
|
653
|
+
Animated.timing(animatedPosition, {
|
|
654
|
+
toValue: {
|
|
655
|
+
x: clampedX,
|
|
656
|
+
y: currentY
|
|
657
|
+
},
|
|
658
|
+
duration: 150,
|
|
659
|
+
useNativeDriver: false
|
|
660
|
+
}).start(() => {
|
|
661
|
+
savePosition(clampedX, currentY);
|
|
662
|
+
// Update savedPositionRef so show/hide works correctly
|
|
663
|
+
if (savedPositionRef.current) {
|
|
664
|
+
savedPositionRef.current.x = clampedX;
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
}, [bubbleSize.width, isPositionInitialized, isLayoutSettled, isDragging, isHidden, animatedPosition, screenWidth, safeAreaInsets.left, savePosition]);
|
|
483
669
|
|
|
484
670
|
// Default position when persistence disabled or during onboarding
|
|
485
671
|
useEffect(() => {
|
|
@@ -496,29 +682,19 @@ export function FloatingTools({
|
|
|
496
682
|
});
|
|
497
683
|
} else {
|
|
498
684
|
// Default right-side position
|
|
499
|
-
const defaultY = Math.max(safeAreaInsets.top +
|
|
685
|
+
const defaultY = Math.max(safeAreaInsets.top + EDGE_PADDING, Math.min(100, screenHeight - bubbleSize.height - safeAreaInsets.bottom));
|
|
500
686
|
animatedPosition.setValue({
|
|
501
|
-
x: screenWidth - bubbleSize.width -
|
|
687
|
+
x: screenWidth - bubbleSize.width - EDGE_PADDING,
|
|
502
688
|
y: defaultY
|
|
503
689
|
});
|
|
504
690
|
}
|
|
505
691
|
}
|
|
506
692
|
}, [enablePositionPersistence, centerOnboarding, animatedPosition, bubbleSize.width, bubbleSize.height, safeAreaInsets.top, safeAreaInsets.bottom, screenWidth, screenHeight]);
|
|
507
693
|
|
|
508
|
-
// Cleanup timeout on component unmount
|
|
509
|
-
useEffect(() => {
|
|
510
|
-
return () => {
|
|
511
|
-
if (saveTimeoutRef.current) {
|
|
512
|
-
clearTimeout(saveTimeoutRef.current);
|
|
513
|
-
saveTimeoutRef.current = null;
|
|
514
|
-
}
|
|
515
|
-
};
|
|
516
|
-
}, []);
|
|
517
|
-
|
|
518
694
|
// Toggle hide/show function
|
|
519
695
|
const toggleHideShow = useCallback(() => {
|
|
520
|
-
const currentX = animatedPosition.x
|
|
521
|
-
const currentY = animatedPosition.y
|
|
696
|
+
const currentX = getAnimatedValue(animatedPosition.x);
|
|
697
|
+
const currentY = getAnimatedValue(animatedPosition.y);
|
|
522
698
|
|
|
523
699
|
// Check if the menu is visually off-screen (more than half hidden to the right)
|
|
524
700
|
// This handles edge cases where isHidden state might be out of sync with actual position
|
|
@@ -533,7 +709,7 @@ export function FloatingTools({
|
|
|
533
709
|
targetY = savedPositionRef.current.y;
|
|
534
710
|
} else {
|
|
535
711
|
// Default visible position if no saved position or saved position is off-screen
|
|
536
|
-
targetX = screenWidth - bubbleSize.width -
|
|
712
|
+
targetX = screenWidth - bubbleSize.width - EDGE_PADDING;
|
|
537
713
|
targetY = currentY;
|
|
538
714
|
}
|
|
539
715
|
|
|
@@ -560,7 +736,7 @@ export function FloatingTools({
|
|
|
560
736
|
|
|
561
737
|
// User explicitly wants the menu hidden
|
|
562
738
|
userWantsVisibleRef.current = false;
|
|
563
|
-
const hiddenX = screenWidth -
|
|
739
|
+
const hiddenX = screenWidth - VISIBLE_HANDLE_WIDTH;
|
|
564
740
|
setIsHidden(true);
|
|
565
741
|
Animated.timing(animatedPosition, {
|
|
566
742
|
toValue: {
|
|
@@ -588,8 +764,20 @@ export function FloatingTools({
|
|
|
588
764
|
const shouldHide = bubbleMidpoint > screenWidth;
|
|
589
765
|
if (shouldHide) {
|
|
590
766
|
// Animate to hidden position (only grabber visible)
|
|
591
|
-
const hiddenX = screenWidth -
|
|
767
|
+
const hiddenX = screenWidth - VISIBLE_HANDLE_WIDTH;
|
|
592
768
|
setIsHidden(true);
|
|
769
|
+
|
|
770
|
+
// Update savedPositionRef to preserve the Y position when dragging while hidden
|
|
771
|
+
// This ensures that when showing, the bubble restores to the new Y position
|
|
772
|
+
if (savedPositionRef.current) {
|
|
773
|
+
savedPositionRef.current.y = currentY;
|
|
774
|
+
} else {
|
|
775
|
+
// If no saved position exists, create one with default visible X
|
|
776
|
+
savedPositionRef.current = {
|
|
777
|
+
x: screenWidth - bubbleSize.width - EDGE_PADDING,
|
|
778
|
+
y: currentY
|
|
779
|
+
};
|
|
780
|
+
}
|
|
593
781
|
Animated.timing(animatedPosition, {
|
|
594
782
|
toValue: {
|
|
595
783
|
x: hiddenX,
|
|
@@ -602,7 +790,7 @@ export function FloatingTools({
|
|
|
602
790
|
});
|
|
603
791
|
} else {
|
|
604
792
|
// Check if we're in hidden state and user is pulling it back
|
|
605
|
-
if (isHidden && currentX < screenWidth -
|
|
793
|
+
if (isHidden && currentX < screenWidth - VISIBLE_HANDLE_WIDTH - 10) {
|
|
606
794
|
setIsHidden(false);
|
|
607
795
|
}
|
|
608
796
|
|
|
@@ -690,6 +878,12 @@ export function FloatingTools({
|
|
|
690
878
|
|
|
691
879
|
// Width for the minimized tools stack - match the drag handle width
|
|
692
880
|
const minimizedStackWidth = 32;
|
|
881
|
+
|
|
882
|
+
// Don't render until position is initialized (when persistence enabled)
|
|
883
|
+
// This prevents the bubble from appearing at {0,0} before position loads
|
|
884
|
+
if (enablePositionPersistence && !isPositionInitialized) {
|
|
885
|
+
return null;
|
|
886
|
+
}
|
|
693
887
|
return /*#__PURE__*/_jsx(Animated.View, {
|
|
694
888
|
style: bubbleStyle,
|
|
695
889
|
children: /*#__PURE__*/_jsxs(View, {
|
|
@@ -739,7 +933,7 @@ export function FloatingTools({
|
|
|
739
933
|
elementSize: bubbleSize,
|
|
740
934
|
minPosition: {
|
|
741
935
|
x: safeAreaInsets.left,
|
|
742
|
-
y: safeAreaInsets.top +
|
|
936
|
+
y: safeAreaInsets.top + EDGE_PADDING
|
|
743
937
|
},
|
|
744
938
|
style: dragHandleStyle,
|
|
745
939
|
enabled: true,
|
package/lib/module/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
+
// Main SDK entry point
|
|
4
|
+
export { Buoy, default as BuoyDefault } from "./Buoy.js";
|
|
3
5
|
// Export unified component - primary interface
|
|
4
6
|
export { FloatingDevTools } from "./floatingMenu/FloatingDevTools";
|
|
5
7
|
// Export auto-discovery utilities
|
|
@@ -26,4 +28,4 @@ export { MinimizedToolsStack } from "./floatingMenu/MinimizedToolsStack.js";
|
|
|
26
28
|
|
|
27
29
|
// Re-export license functionality from @buoy-gg/license
|
|
28
30
|
// These are re-exported for convenience so users can import from @buoy-gg/core
|
|
29
|
-
export { LicenseManager, useLicense, useFeatureAccess,
|
|
31
|
+
export { LicenseManager, DeviceRegistrationModal, useLicense, useFeatureAccess, useIsPro, setLicenseKey, isPro, hasEntitlement } from "@buoy-gg/license";
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Buoy SDK - Main entry point for React Buoy configuration
|
|
3
|
+
*
|
|
4
|
+
* Initialize once at app startup, before rendering any components:
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```tsx
|
|
8
|
+
* import { Buoy } from '@buoy-gg/core';
|
|
9
|
+
*
|
|
10
|
+
* Buoy.init({
|
|
11
|
+
* licenseKey: 'YOUR_LICENSE_KEY',
|
|
12
|
+
* });
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
export interface BuoyConfig {
|
|
16
|
+
/**
|
|
17
|
+
* Your Buoy Pro license key
|
|
18
|
+
* Get one at https://buoy.gg/pricing
|
|
19
|
+
*/
|
|
20
|
+
licenseKey: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Buoy SDK namespace
|
|
24
|
+
*
|
|
25
|
+
* Use `Buoy.init()` to configure your license key at app startup.
|
|
26
|
+
*/
|
|
27
|
+
export declare const Buoy: {
|
|
28
|
+
/**
|
|
29
|
+
* Initialize Buoy with your license key
|
|
30
|
+
*
|
|
31
|
+
* Call this once at app startup, before rendering FloatingDevTools.
|
|
32
|
+
* Typically placed in App.tsx or index.js.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```tsx
|
|
36
|
+
* import { Buoy } from '@buoy-gg/core';
|
|
37
|
+
*
|
|
38
|
+
* // With hardcoded key
|
|
39
|
+
* Buoy.init({
|
|
40
|
+
* licenseKey: '36063C-27282E-8E73F5-55488F-2213DF-V3',
|
|
41
|
+
* });
|
|
42
|
+
*
|
|
43
|
+
* // With environment variable
|
|
44
|
+
* Buoy.init({
|
|
45
|
+
* licenseKey: process.env.BUOY_LICENSE_KEY!,
|
|
46
|
+
* });
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
init(config: BuoyConfig): void;
|
|
50
|
+
/**
|
|
51
|
+
* Initialize Buoy asynchronously
|
|
52
|
+
*
|
|
53
|
+
* Use this if you need to wait for initialization to complete.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```tsx
|
|
57
|
+
* await Buoy.initAsync({
|
|
58
|
+
* licenseKey: 'YOUR_LICENSE_KEY',
|
|
59
|
+
* });
|
|
60
|
+
* console.log('Buoy is ready!');
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
initAsync(config: BuoyConfig): Promise<boolean>;
|
|
64
|
+
/**
|
|
65
|
+
* Check if Buoy Pro is active
|
|
66
|
+
*/
|
|
67
|
+
isPro(): boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Get the current license state
|
|
70
|
+
*/
|
|
71
|
+
getState(): Readonly<import("@buoy-gg/license").LicenseState>;
|
|
72
|
+
/**
|
|
73
|
+
* Force device re-registration (debugging helper)
|
|
74
|
+
* Clears cached fingerprint so next init will register device again
|
|
75
|
+
*/
|
|
76
|
+
forceReregister(): Promise<void>;
|
|
77
|
+
};
|
|
78
|
+
export default Buoy;
|
|
79
|
+
//# sourceMappingURL=Buoy.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Buoy.d.ts","sourceRoot":"","sources":["../../../src/Buoy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAIH,MAAM,WAAW,UAAU;IACzB;;;OAGG;IACH,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;GAIG;AACH,eAAO,MAAM,IAAI;IACf;;;;;;;;;;;;;;;;;;;;OAoBG;iBACU,UAAU,GAAG,IAAI;IAc9B;;;;;;;;;;;;OAYG;sBACqB,UAAU,GAAG,OAAO,CAAC,OAAO,CAAC;IAUrD;;OAEG;aACM,OAAO;IAIhB;;OAEG;;IAKH;;;OAGG;uBACsB,OAAO,CAAC,IAAI,CAAC;CAGvC,CAAC;AAGF,eAAe,IAAI,CAAC"}
|