@buoy-gg/core 7.0.36 → 7.0.39

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.
Files changed (103) hide show
  1. package/README.md +57 -69
  2. package/bin/buoy.js +71 -20
  3. package/lib/commonjs/Buoy.js +3 -16
  4. package/lib/commonjs/floatingMenu/AppHost.js +1 -1
  5. package/lib/commonjs/floatingMenu/AppHost.web.js +1 -1
  6. package/lib/commonjs/floatingMenu/AppIcon.web.js +53 -0
  7. package/lib/commonjs/floatingMenu/DevToolsSettingsModal.js +12 -5
  8. package/lib/commonjs/floatingMenu/DevToolsSettingsModal.web.js +14 -34
  9. package/lib/commonjs/floatingMenu/FloatingDevTools.js +32 -48
  10. package/lib/commonjs/floatingMenu/FloatingDevTools.web.js +24 -4
  11. package/lib/commonjs/floatingMenu/FloatingMenu.js +1 -1
  12. package/lib/commonjs/floatingMenu/ToolHost.js +1 -0
  13. package/lib/commonjs/floatingMenu/autoExternalSync.js +3 -2
  14. package/lib/commonjs/floatingMenu/dial/DialDevTools.js +43 -10
  15. package/lib/commonjs/floatingMenu/dial/DialDevTools.web.js +18 -5
  16. package/lib/commonjs/floatingMenu/dial/dialUsageStore.js +13 -17
  17. package/lib/commonjs/floatingMenu/dial/onboardingConstants.js +37 -65
  18. package/lib/commonjs/floatingMenu/dial/onboardingConstants.web.js +37 -29
  19. package/lib/commonjs/floatingMenu/floatingTools.js +23 -889
  20. package/lib/commonjs/floatingMenu/floatingTools.web.js +24 -514
  21. package/lib/commonjs/floatingMenu/optionalRequire.js +4 -0
  22. package/lib/commonjs/telemetry/installPing.js +7 -0
  23. package/lib/commonjs/version.js +1 -1
  24. package/lib/module/Buoy.js +3 -16
  25. package/lib/module/floatingMenu/AppHost.js +1 -1
  26. package/lib/module/floatingMenu/AppHost.web.js +1 -1
  27. package/lib/module/floatingMenu/AppIcon.web.js +47 -0
  28. package/lib/module/floatingMenu/DevToolsSettingsModal.js +13 -6
  29. package/lib/module/floatingMenu/DevToolsSettingsModal.web.js +15 -35
  30. package/lib/module/floatingMenu/FloatingDevTools.js +35 -49
  31. package/lib/module/floatingMenu/FloatingDevTools.web.js +24 -3
  32. package/lib/module/floatingMenu/FloatingMenu.js +1 -1
  33. package/lib/module/floatingMenu/ToolHost.js +1 -0
  34. package/lib/module/floatingMenu/autoExternalSync.js +3 -2
  35. package/lib/module/floatingMenu/dial/DialDevTools.js +43 -10
  36. package/lib/module/floatingMenu/dial/DialDevTools.web.js +18 -5
  37. package/lib/module/floatingMenu/dial/dialUsageStore.js +14 -18
  38. package/lib/module/floatingMenu/dial/onboardingConstants.js +1 -62
  39. package/lib/module/floatingMenu/dial/onboardingConstants.web.js +1 -25
  40. package/lib/module/floatingMenu/floatingTools.js +15 -890
  41. package/lib/module/floatingMenu/floatingTools.web.js +1 -517
  42. package/lib/module/floatingMenu/optionalRequire.js +4 -0
  43. package/lib/module/telemetry/installPing.js +7 -0
  44. package/lib/module/version.js +1 -1
  45. package/lib/typescript/commonjs/Buoy.d.ts +14 -3
  46. package/lib/typescript/commonjs/Buoy.d.ts.map +1 -1
  47. package/lib/typescript/commonjs/floatingMenu/AppIcon.web.d.ts +14 -0
  48. package/lib/typescript/commonjs/floatingMenu/AppIcon.web.d.ts.map +1 -0
  49. package/lib/typescript/commonjs/floatingMenu/DevToolsSettingsModal.d.ts.map +1 -1
  50. package/lib/typescript/commonjs/floatingMenu/DevToolsSettingsModal.web.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/floatingMenu/FloatingDevTools.d.ts +4 -14
  52. package/lib/typescript/commonjs/floatingMenu/FloatingDevTools.d.ts.map +1 -1
  53. package/lib/typescript/commonjs/floatingMenu/FloatingDevTools.web.d.ts +4 -19
  54. package/lib/typescript/commonjs/floatingMenu/FloatingDevTools.web.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/floatingMenu/ToolHost.d.ts.map +1 -1
  56. package/lib/typescript/commonjs/floatingMenu/autoExternalSync.d.ts +3 -4
  57. package/lib/typescript/commonjs/floatingMenu/autoExternalSync.d.ts.map +1 -1
  58. package/lib/typescript/commonjs/floatingMenu/dial/DialDevTools.d.ts.map +1 -1
  59. package/lib/typescript/commonjs/floatingMenu/dial/DialDevTools.web.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/floatingMenu/dial/dialUsageStore.d.ts +3 -15
  61. package/lib/typescript/commonjs/floatingMenu/dial/dialUsageStore.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/floatingMenu/dial/onboardingConstants.d.ts +1 -29
  63. package/lib/typescript/commonjs/floatingMenu/dial/onboardingConstants.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/floatingMenu/dial/onboardingConstants.web.d.ts +1 -12
  65. package/lib/typescript/commonjs/floatingMenu/dial/onboardingConstants.web.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/floatingMenu/floatingTools.d.ts +6 -55
  67. package/lib/typescript/commonjs/floatingMenu/floatingTools.d.ts.map +1 -1
  68. package/lib/typescript/commonjs/floatingMenu/floatingTools.web.d.ts +3 -53
  69. package/lib/typescript/commonjs/floatingMenu/floatingTools.web.d.ts.map +1 -1
  70. package/lib/typescript/commonjs/floatingMenu/optionalRequire.d.ts.map +1 -1
  71. package/lib/typescript/commonjs/telemetry/installPing.d.ts +8 -1
  72. package/lib/typescript/commonjs/telemetry/installPing.d.ts.map +1 -1
  73. package/lib/typescript/commonjs/version.d.ts +1 -1
  74. package/lib/typescript/module/Buoy.d.ts +14 -3
  75. package/lib/typescript/module/Buoy.d.ts.map +1 -1
  76. package/lib/typescript/module/floatingMenu/AppIcon.web.d.ts +14 -0
  77. package/lib/typescript/module/floatingMenu/AppIcon.web.d.ts.map +1 -0
  78. package/lib/typescript/module/floatingMenu/DevToolsSettingsModal.d.ts.map +1 -1
  79. package/lib/typescript/module/floatingMenu/DevToolsSettingsModal.web.d.ts.map +1 -1
  80. package/lib/typescript/module/floatingMenu/FloatingDevTools.d.ts +4 -14
  81. package/lib/typescript/module/floatingMenu/FloatingDevTools.d.ts.map +1 -1
  82. package/lib/typescript/module/floatingMenu/FloatingDevTools.web.d.ts +4 -19
  83. package/lib/typescript/module/floatingMenu/FloatingDevTools.web.d.ts.map +1 -1
  84. package/lib/typescript/module/floatingMenu/ToolHost.d.ts.map +1 -1
  85. package/lib/typescript/module/floatingMenu/autoExternalSync.d.ts +3 -4
  86. package/lib/typescript/module/floatingMenu/autoExternalSync.d.ts.map +1 -1
  87. package/lib/typescript/module/floatingMenu/dial/DialDevTools.d.ts.map +1 -1
  88. package/lib/typescript/module/floatingMenu/dial/DialDevTools.web.d.ts.map +1 -1
  89. package/lib/typescript/module/floatingMenu/dial/dialUsageStore.d.ts +3 -15
  90. package/lib/typescript/module/floatingMenu/dial/dialUsageStore.d.ts.map +1 -1
  91. package/lib/typescript/module/floatingMenu/dial/onboardingConstants.d.ts +1 -29
  92. package/lib/typescript/module/floatingMenu/dial/onboardingConstants.d.ts.map +1 -1
  93. package/lib/typescript/module/floatingMenu/dial/onboardingConstants.web.d.ts +1 -12
  94. package/lib/typescript/module/floatingMenu/dial/onboardingConstants.web.d.ts.map +1 -1
  95. package/lib/typescript/module/floatingMenu/floatingTools.d.ts +6 -55
  96. package/lib/typescript/module/floatingMenu/floatingTools.d.ts.map +1 -1
  97. package/lib/typescript/module/floatingMenu/floatingTools.web.d.ts +3 -53
  98. package/lib/typescript/module/floatingMenu/floatingTools.web.d.ts.map +1 -1
  99. package/lib/typescript/module/floatingMenu/optionalRequire.d.ts.map +1 -1
  100. package/lib/typescript/module/telemetry/installPing.d.ts +8 -1
  101. package/lib/typescript/module/telemetry/installPing.d.ts.map +1 -1
  102. package/lib/typescript/module/version.d.ts +1 -1
  103. package/package.json +6 -6
@@ -3,906 +3,40 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
- exports.Divider = Divider;
6
+ Object.defineProperty(exports, "Divider", {
7
+ enumerable: true,
8
+ get: function () {
9
+ return _sharedUi.FloatingToolDivider;
10
+ }
11
+ });
7
12
  exports.FloatingTools = FloatingTools;
8
- exports.UserStatus = UserStatus;
13
+ Object.defineProperty(exports, "UserStatus", {
14
+ enumerable: true,
15
+ get: function () {
16
+ return _sharedUi.FloatingToolUserStatus;
17
+ }
18
+ });
9
19
  var _react = require("react");
10
- var _reactNative = require("react-native");
11
20
  var _sharedUi = require("@buoy-gg/shared-ui");
12
- var _onboardingConstants = require("./dial/onboardingConstants");
13
21
  var _MinimizedToolsStack = require("./MinimizedToolsStack");
14
22
  var _MinimizedToolsContext = require("./MinimizedToolsContext.js");
15
23
  var _AppHost = require("./AppHost");
16
- var _floatingToolsCore = require("@buoy-gg/floating-tools-core");
17
24
  var _jsxRuntime = require("react/jsx-runtime");
18
- // Shared utilities from floating-tools-core
19
-
20
- // Re-export UserRole type for consumers
21
-
22
- // Using Views to render grip dots; no react-native-svg dependency
23
-
24
- const STORAGE_KEYS = {
25
- BUBBLE_POSITION_X: "@react_buoy_bubble_position_x",
26
- BUBBLE_POSITION_Y: "@react_buoy_bubble_position_y"
27
- };
28
-
29
- // Position constants
30
- const VISIBLE_HANDLE_WIDTH = 32;
31
- const EDGE_PADDING = 20;
32
- const LAYOUT_SETTLE_DELAY_MS = 150;
33
-
34
- /** Helper to get current value from Animated.Value without triggering updates */
35
- const getAnimatedValue = value => value.__getValue();
36
-
37
- // =============================
38
- // Position persistence hook
39
- // Extracted logic dedicated to state/IO
40
- // =============================
41
- /**
42
- * Custom hook for managing floating tools position persistence
43
- *
44
- * Handles loading, saving, and validating the position of the floating tools bubble
45
- * with automatic boundary checking and storage management.
46
- *
47
- * @param props - Configuration for position management
48
- * @param props.animatedPosition - Animated.ValueXY for position updates
49
- * @param props.bubbleWidth - Width of the bubble for boundary calculations
50
- * @param props.bubbleHeight - Height of the bubble for boundary calculations
51
- * @param props.enabled - Whether position persistence is enabled
52
- * @param props.visibleHandleWidth - Width of visible handle when bubble is hidden
53
- * @param props.listenersSuspended - Pause automatic listeners without disabling manual saves
54
- * @param props.onPositionLoaded - Callback fired once position is loaded and set
55
- *
56
- * @returns Object containing position management functions and loading state
57
- *
58
- * @performance Uses debounced saving to avoid excessive storage operations
59
- * @performance Validates positions against screen boundaries and safe areas
60
- */
61
- function useFloatingToolsPosition({
62
- animatedPosition,
63
- bubbleWidth = 100,
64
- bubbleHeight = 32,
65
- enabled = true,
66
- visibleHandleWidth = 32,
67
- listenersSuspended = false,
68
- onPositionLoaded
69
- }) {
70
- // Use state instead of ref so we can react to changes and prevent races
71
- const [isInitialized, setIsInitialized] = (0, _react.useState)(false);
72
- const saveTimeoutRef = (0, _react.useRef)(undefined);
73
- // Track if component is mounted to prevent state updates after unmount
74
- const isMountedRef = (0, _react.useRef)(true);
75
-
76
- // Cleanup on unmount - cancel any pending saves
77
- (0, _react.useEffect)(() => {
78
- isMountedRef.current = true;
79
- return () => {
80
- isMountedRef.current = false;
81
- // Clear any pending debounced saves on unmount to prevent saving stale/invalid positions
82
- if (saveTimeoutRef.current) {
83
- clearTimeout(saveTimeoutRef.current);
84
- saveTimeoutRef.current = undefined;
85
- }
86
- };
87
- }, []);
88
- const savePosition = (0, _react.useCallback)(async (x, y) => {
89
- if (!enabled) return;
90
- // Don't save if component unmounted or position is clearly invalid
91
- if (!isMountedRef.current) return;
92
- // Guard against saving invalid positions (e.g., during HMR with stale dimensions)
93
- const {
94
- width: screenWidth
95
- } = _reactNative.Dimensions.get("window");
96
- if (screenWidth <= 0 || x < 0 || y < 0) return;
97
- try {
98
- await Promise.all([_sharedUi.persistentStorage.setItem(STORAGE_KEYS.BUBBLE_POSITION_X, x.toString()), _sharedUi.persistentStorage.setItem(STORAGE_KEYS.BUBBLE_POSITION_Y, y.toString())]);
99
- } catch (error) {
100
- // Failed to save position - continue without persistence
101
- }
102
- }, [enabled]);
103
- const debouncedSavePosition = (0, _react.useCallback)((x, y) => {
104
- // Don't schedule saves if unmounted
105
- if (!isMountedRef.current) return;
106
- if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
107
- saveTimeoutRef.current = setTimeout(() => {
108
- // Double-check mount status before saving
109
- if (isMountedRef.current) {
110
- savePosition(x, y);
111
- }
112
- }, 500);
113
- }, [savePosition]);
114
- const loadPosition = (0, _react.useCallback)(async () => {
115
- if (!enabled) return null;
116
- try {
117
- const [xStr, yStr] = await Promise.all([_sharedUi.persistentStorage.getItem(STORAGE_KEYS.BUBBLE_POSITION_X), _sharedUi.persistentStorage.getItem(STORAGE_KEYS.BUBBLE_POSITION_Y)]);
118
- if (xStr !== null && yStr !== null) {
119
- const x = parseFloat(xStr);
120
- const y = parseFloat(yStr);
121
- if (!Number.isNaN(x) && !Number.isNaN(y)) {
122
- return {
123
- x,
124
- y
125
- };
126
- }
127
- }
128
- } catch (error) {
129
- // Failed to load position - use default
130
- }
131
- return null;
132
- }, [enabled]);
133
- const validatePosition = (0, _react.useCallback)(position => {
134
- const {
135
- width: screenWidth,
136
- height: screenHeight
137
- } = _reactNative.Dimensions.get("window");
138
- const safeArea = (0, _sharedUi.getSafeAreaInsets)();
139
-
140
- // Guard against invalid screen dimensions (can happen during HMR)
141
- // Return the position as-is but mark as invalid so caller knows not to trust it
142
- if (screenWidth <= 0 || screenHeight <= 0) {
143
- return {
144
- ...position,
145
- isValid: false
146
- };
147
- }
148
-
149
- // Prevent going off left, top, and bottom edges with safe area
150
- // Allow pushing off-screen to the right so only the grab handle remains visible
151
- const minX = safeArea.left;
152
- const maxX = screenWidth - visibleHandleWidth;
153
- // Add padding below the safe area top to ensure bubble doesn't go behind notch
154
- const minY = safeArea.top + EDGE_PADDING;
155
- const maxY = screenHeight - bubbleHeight - safeArea.bottom; // Respect safe area bottom
156
- const clamped = {
157
- x: Math.max(minX, Math.min(position.x, maxX)),
158
- y: Math.max(minY, Math.min(position.y, maxY)),
159
- isValid: true
160
- };
161
- return clamped;
162
- }, [visibleHandleWidth, bubbleHeight]);
163
- (0, _react.useEffect)(() => {
164
- if (!enabled || isInitialized) return;
165
- let cancelled = false;
166
- const restore = async () => {
167
- const saved = await loadPosition();
168
-
169
- // Check if cancelled or unmounted before updating state
170
- if (cancelled || !isMountedRef.current) return;
171
- const {
172
- width: screenWidth,
173
- height: screenHeight
174
- } = _reactNative.Dimensions.get("window");
175
- const safeArea = (0, _sharedUi.getSafeAreaInsets)();
176
-
177
- // If dimensions are invalid (HMR scenario), wait and retry
178
- if (screenWidth <= 0 || screenHeight <= 0) {
179
- // Schedule a retry after a short delay
180
- setTimeout(() => {
181
- if (isMountedRef.current && !cancelled) {
182
- restore();
183
- }
184
- }, 100);
185
- return;
186
- }
187
- let finalPosition;
188
- let wasHidden = false;
189
- if (saved) {
190
- const validated = validatePosition(saved);
191
-
192
- // If validation returned invalid, retry later
193
- if (!validated.isValid) {
194
- setTimeout(() => {
195
- if (isMountedRef.current && !cancelled) {
196
- restore();
197
- }
198
- }, 100);
199
- return;
200
- }
201
-
202
- // Check if the saved position is out of bounds
203
- const wasOutOfBounds = Math.abs(saved.x - validated.x) > 5 || Math.abs(saved.y - validated.y) > 5;
204
- if (wasOutOfBounds) {
205
- // Save the corrected position
206
- await savePosition(validated.x, validated.y);
207
- }
208
- finalPosition = {
209
- x: validated.x,
210
- y: validated.y
211
- };
212
-
213
- // Check if loaded in hidden state (bubble pushed to right edge)
214
- wasHidden = validated.x >= screenWidth - visibleHandleWidth - 5;
215
- } else {
216
- const defaultY = Math.max(safeArea.top + EDGE_PADDING, Math.min(100, screenHeight - bubbleHeight - safeArea.bottom));
217
- finalPosition = {
218
- x: screenWidth - bubbleWidth - EDGE_PADDING,
219
- y: defaultY
220
- };
221
- }
222
-
223
- // Final mount check before updating
224
- if (cancelled || !isMountedRef.current) return;
225
- animatedPosition.setValue(finalPosition);
226
- setIsInitialized(true);
227
- onPositionLoaded?.(finalPosition, wasHidden);
228
- };
229
- restore();
230
- return () => {
231
- cancelled = true;
232
- };
233
- }, [enabled, isInitialized, animatedPosition, loadPosition, validatePosition, savePosition, bubbleWidth, bubbleHeight, visibleHandleWidth, onPositionLoaded]);
234
-
235
- // Listener effect - now properly depends on isInitialized state
236
- (0, _react.useEffect)(() => {
237
- // Don't attach listener until position is loaded
238
- if (!enabled || !isInitialized || listenersSuspended) return;
239
- const listener = animatedPosition.addListener(value => {
240
- debouncedSavePosition(value.x, value.y);
241
- });
242
- return () => {
243
- animatedPosition.removeListener(listener);
244
- // Clear debounce timer when listener is removed
245
- if (saveTimeoutRef.current) {
246
- clearTimeout(saveTimeoutRef.current);
247
- saveTimeoutRef.current = undefined;
248
- }
249
- };
250
- }, [enabled, isInitialized, listenersSuspended, animatedPosition, debouncedSavePosition]);
251
- return {
252
- savePosition,
253
- isInitialized
254
- };
255
- }
256
-
257
- // =============================
258
- // UI-only leaf components
259
- // =============================
260
- function Divider() {
261
- const dividerStyle = {
262
- width: 1,
263
- height: 12,
264
- backgroundColor: _sharedUi.buoyColors.textMuted + "66",
265
- flexShrink: 0
266
- };
267
- return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
268
- style: dividerStyle
269
- });
270
- }
271
-
272
- // Use shared getUserStatusConfig from floating-tools-core
273
- // Wraps the core function to use our buoyColors (mapped to gameUIColors format)
274
- const gameUIColorsCompat = {
275
- success: "#20C997",
276
- // teal
277
- warning: "#FFA94D",
278
- error: "#EF4444",
279
- muted: _sharedUi.buoyColors.textMuted,
280
- secondary: _sharedUi.buoyColors.textSecondary,
281
- info: "#20C997",
282
- // teal
283
- primary: "#20C997",
284
- optional: _sharedUi.buoyColors.textSecondary // optional status color
285
- };
286
- function getUserStatusConfig(userRole) {
287
- return (0, _floatingToolsCore.getUserStatusConfig)(userRole, gameUIColorsCompat);
288
- }
289
-
290
- // Context to avoid brittle prop threading and keep API composable
291
- const FloatingToolsContext = /*#__PURE__*/(0, _react.createContext)({
292
- isDragging: false
293
- });
294
- function UserStatus({
295
- userRole,
296
- onPress
297
- }) {
298
- const {
299
- isDragging
300
- } = (0, _react.useContext)(FloatingToolsContext);
301
- const config = getUserStatusConfig(userRole);
302
- const containerStyle = {
303
- flexDirection: "row",
304
- alignItems: "center",
305
- paddingVertical: 6,
306
- paddingHorizontal: 8,
307
- flexShrink: 0
308
- };
309
- const dotStyle = {
310
- width: 6,
311
- height: 6,
312
- borderRadius: 3,
313
- backgroundColor: config.dotColor,
314
- marginRight: 4
315
- };
316
- const textStyle = {
317
- fontSize: 10,
318
- fontWeight: "500",
319
- color: config.textColor,
320
- letterSpacing: 0.3
321
- };
322
- if (!onPress) {
323
- return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
324
- style: containerStyle,
325
- accessibilityLabel: `User status: ${config.label}`,
326
- children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
327
- style: dotStyle
328
- }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
329
- style: textStyle,
330
- children: config.label
331
- })]
332
- });
333
- }
334
- return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.TouchableOpacity, {
335
- accessibilityRole: "button",
336
- accessibilityLabel: "Open Dev Tools Menu",
337
- testID: "open-dev-tools-button",
338
- onPress: onPress,
339
- hitSlop: {
340
- top: 10,
341
- bottom: 10,
342
- left: 0,
343
- right: 10
344
- },
345
- disabled: isDragging,
346
- activeOpacity: 0.85,
347
- style: containerStyle,
348
- children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
349
- style: dotStyle
350
- }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
351
- style: textStyle,
352
- children: config.label
353
- })]
354
- });
355
- }
356
-
357
- // =============================
358
- // Helpers (using shared filterValidChildren from core)
359
- // =============================
360
- function interleaveWithDividers(childrenArray) {
361
- const validChildren = (0, _floatingToolsCore.filterValidChildren)(childrenArray);
362
- const result = [];
363
- validChildren.forEach((child, index) => {
364
- result.push(child);
365
- if (index < validChildren.length - 1) result.push(/*#__PURE__*/(0, _jsxRuntime.jsx)(Divider, {}, `divider-${index}`));
366
- });
367
- return result;
368
- }
369
-
370
- // =============================
371
- // Main Component (presentation only)
372
- // =============================
373
-
374
- /**
375
- * FloatingTools - A draggable, resizable bubble for development tools
376
- *
377
- * This component provides a floating bubble interface that can contain various
378
- * development tools and controls. It features:
379
- * - Drag and drop positioning with boundary constraints
380
- * - Hide/show functionality by dragging to screen edge
381
- * - Position persistence across app restarts
382
- * - Safe area aware positioning
383
- * - Automatic divider insertion between child components
384
- *
385
- * @param props - Configuration for the floating tools
386
- * @param props.enablePositionPersistence - Whether to save/restore position (default: true)
387
- * @param props.children - Child components to render in the bubble
388
- *
389
- * @returns JSX.Element representing the floating tools bubble
390
- *
391
- * @example
392
- * ```typescript
393
- * <FloatingTools enablePositionPersistence={true}>
394
- * <UserStatus userRole="admin" onPress={handleUserPress} />
395
- * <ToolButton onPress={openSettings} />
396
- * </FloatingTools>
397
- * ```
398
- *
399
- * @performance Uses native driver animations for smooth positioning
400
- * @performance Implements efficient boundary checking and position validation
401
- * @performance Includes debounced position saving for optimal storage performance
402
- */
403
- function FloatingTools({
404
- enablePositionPersistence = true,
405
- pushToSide = false,
406
- centerOnboarding = false,
407
- children,
408
- environment,
409
- availableEnvironments,
410
- onEnvironmentSwitch,
411
- showEnvironmentSelector = false
412
- }) {
413
- // Animated position and drag state
414
- const animatedPosition = (0, _react.useRef)(new _reactNative.Animated.ValueXY()).current;
415
- const [isDragging, setIsDragging] = (0, _react.useState)(false);
416
- const [bubbleSize, setBubbleSize] = (0, _react.useState)({
417
- width: 100,
418
- height: 32
419
- });
420
- const [isHidden, setIsHidden] = (0, _react.useState)(false);
421
-
422
- // Store the position before hiding to restore when showing
423
- const savedPositionRef = (0, _react.useRef)(null);
424
-
425
- // Track if the hide state was triggered by pushToSide prop vs user toggle
426
- const isPushedBySideRef = (0, _react.useRef)(false);
427
-
428
- // Track if user has explicitly chosen to show the menu (overriding pushToSide)
429
- const userWantsVisibleRef = (0, _react.useRef)(false);
430
-
431
- // Track previous pushToSide value to detect transitions
432
- const prevPushToSideRef = (0, _react.useRef)(pushToSide);
433
-
434
- // Track previous bubble width for auto-adjustment when tools are added/removed
435
- const prevBubbleWidthRef = (0, _react.useRef)(null);
436
-
437
- // Track if layout has settled (after initial render cycle completes)
438
- const [isLayoutSettled, setIsLayoutSettled] = (0, _react.useState)(false);
439
- const safeAreaInsets = (0, _sharedUi.useSafeAreaInsets)();
440
- const {
441
- width: screenWidth,
442
- height: screenHeight
443
- } = _reactNative.Dimensions.get("window");
444
-
445
- // Callback when position is loaded - sets hidden state properly without racing
446
- const handlePositionLoaded = (0, _react.useCallback)((position, wasHidden) => {
447
- if (wasHidden) {
448
- setIsHidden(true);
449
- }
450
- }, []);
451
-
452
- // Position persistence (state/IO extracted to hook)
453
- const {
454
- savePosition,
455
- isInitialized: isPositionInitialized
456
- } = useFloatingToolsPosition({
457
- animatedPosition,
458
- bubbleWidth: bubbleSize.width,
459
- bubbleHeight: bubbleSize.height,
460
- enabled: enablePositionPersistence,
461
- visibleHandleWidth: VISIBLE_HANDLE_WIDTH,
462
- listenersSuspended: isDragging,
463
- onPositionLoaded: handlePositionLoaded
464
- });
465
-
466
- // Effect to handle pushToSide prop changes
467
- (0, _react.useEffect)(() => {
468
- // Don't process pushToSide until position is initialized (when persistence enabled)
469
- if (enablePositionPersistence && !isPositionInitialized) return;
470
-
471
- // Reset user override when pushToSide becomes true (dial/modal opens)
472
- // This allows auto-hide to work after user manually showed the menu
473
- if (!prevPushToSideRef.current && pushToSide) {
474
- userWantsVisibleRef.current = false;
475
- }
476
- prevPushToSideRef.current = pushToSide;
477
-
478
- // Only push to side if:
479
- // 1. pushToSide is true
480
- // 2. Not currently hidden
481
- // 3. Not being dragged
482
- // 4. User hasn't manually restored (userWantsVisibleRef)
483
- if (pushToSide && !isHidden && !isDragging && !userWantsVisibleRef.current) {
484
- // Push to side
485
- const currentX = getAnimatedValue(animatedPosition.x);
486
- const currentY = getAnimatedValue(animatedPosition.y);
487
-
488
- // Save current position
489
- savedPositionRef.current = {
490
- x: currentX,
491
- y: currentY
492
- };
493
- const hiddenX = screenWidth - VISIBLE_HANDLE_WIDTH;
494
- isPushedBySideRef.current = true;
495
- setIsHidden(true);
496
- _reactNative.Animated.timing(animatedPosition, {
497
- toValue: {
498
- x: hiddenX,
499
- y: currentY
500
- },
501
- duration: 200,
502
- useNativeDriver: false
503
- }).start(() => {
504
- savePosition(hiddenX, currentY);
505
- });
506
- } else if (!pushToSide && isHidden && isPushedBySideRef.current) {
507
- // Restore from side when pushToSide becomes false
508
- if (savedPositionRef.current) {
509
- const {
510
- x: targetX,
511
- y: targetY
512
- } = savedPositionRef.current;
513
- isPushedBySideRef.current = false;
514
- userWantsVisibleRef.current = false; // Reset user override when tools close
515
- setIsHidden(false);
516
- _reactNative.Animated.timing(animatedPosition, {
517
- toValue: {
518
- x: targetX,
519
- y: targetY
520
- },
521
- duration: 200,
522
- useNativeDriver: false
523
- }).start(() => {
524
- savePosition(targetX, targetY);
525
- });
526
- }
527
- }
528
- }, [enablePositionPersistence, isPositionInitialized, pushToSide, isHidden, isDragging, screenWidth, animatedPosition, savePosition]);
529
-
530
- // Hidden state is now set via onPositionLoaded callback - no racing effect needed
531
-
532
- // Wait for layout to settle before enabling width tracking
533
- // This prevents false-positive width changes during initial render when
534
- // the bubble measures multiple times as children render
535
- (0, _react.useEffect)(() => {
536
- if (!isPositionInitialized || isLayoutSettled) return;
537
- const timer = setTimeout(() => {
538
- // Capture the current width as the baseline AFTER layout has settled
539
- prevBubbleWidthRef.current = bubbleSize.width;
540
- setIsLayoutSettled(true);
541
- }, LAYOUT_SETTLE_DELAY_MS);
542
- return () => clearTimeout(timer);
543
- }, [isPositionInitialized, isLayoutSettled, bubbleSize.width]);
544
-
545
- // Auto-adjust position when bubble width changes (tools added/removed)
546
- // This keeps the bubble's right edge in the same relative position
547
- (0, _react.useEffect)(() => {
548
- // Skip if position not initialized yet
549
- if (!isPositionInitialized) return;
550
-
551
- // Skip until layout has settled (prevents false-positive width changes during initial render)
552
- if (!isLayoutSettled) return;
553
-
554
- // Skip if prevBubbleWidthRef hasn't been set yet
555
- if (prevBubbleWidthRef.current === null) return;
556
-
557
- // Skip if currently dragging - let drag handle position
558
- if (isDragging) {
559
- prevBubbleWidthRef.current = bubbleSize.width;
560
- return;
561
- }
562
- const prevWidth = prevBubbleWidthRef.current;
563
- const newWidth = bubbleSize.width;
564
- const widthDelta = newWidth - prevWidth;
565
-
566
- // Skip if no significant change (avoid jitter)
567
- if (Math.abs(widthDelta) < 2) {
568
- return;
569
- }
570
-
571
- // Update ref first
572
- prevBubbleWidthRef.current = newWidth;
573
-
574
- // If hidden, only adjust the saved position (not the current animated position)
575
- if (isHidden) {
576
- if (savedPositionRef.current) {
577
- const adjustedX = savedPositionRef.current.x - widthDelta;
578
- // Clamp to valid bounds
579
- savedPositionRef.current.x = Math.max(safeAreaInsets.left, Math.min(adjustedX, screenWidth - newWidth - EDGE_PADDING));
580
- }
581
- return;
582
- }
583
-
584
- // Get current position
585
- const currentX = getAnimatedValue(animatedPosition.x);
586
- const currentY = getAnimatedValue(animatedPosition.y);
587
-
588
- // Calculate new X position
589
- // If bubble grew (positive delta), move left (decrease X)
590
- // If bubble shrunk (negative delta), move right (increase X)
591
- const newX = currentX - widthDelta;
592
-
593
- // Validate new position is within bounds
594
- const clampedX = Math.max(safeAreaInsets.left, Math.min(newX, screenWidth - VISIBLE_HANDLE_WIDTH));
595
-
596
- // Only animate if position actually changed
597
- if (Math.abs(clampedX - currentX) > 1) {
598
- _reactNative.Animated.timing(animatedPosition, {
599
- toValue: {
600
- x: clampedX,
601
- y: currentY
602
- },
603
- duration: 150,
604
- useNativeDriver: false
605
- }).start(() => {
606
- savePosition(clampedX, currentY);
607
- // Update savedPositionRef so show/hide works correctly
608
- if (savedPositionRef.current) {
609
- savedPositionRef.current.x = clampedX;
610
- }
611
- });
612
- }
613
- }, [bubbleSize.width, isPositionInitialized, isLayoutSettled, isDragging, isHidden, animatedPosition, screenWidth, safeAreaInsets.left, savePosition]);
614
-
615
- // Default position when persistence disabled or during onboarding
616
- (0, _react.useEffect)(() => {
617
- if (!enablePositionPersistence) {
618
- if (centerOnboarding) {
619
- // Center the bubble for onboarding - position under tooltip arrow
620
- // Use shared calculation to ensure perfect alignment across all screens
621
- const bottomOffset = (0, _onboardingConstants.calculateTargetPosition)();
622
- const centerX = (screenWidth - bubbleSize.width) / 2;
623
- const centerY = screenHeight - bubbleSize.height - bottomOffset;
624
- animatedPosition.setValue({
625
- x: centerX,
626
- y: centerY
627
- });
628
- } else {
629
- // Default right-side position
630
- const defaultY = Math.max(safeAreaInsets.top + EDGE_PADDING, Math.min(100, screenHeight - bubbleSize.height - safeAreaInsets.bottom));
631
- animatedPosition.setValue({
632
- x: screenWidth - bubbleSize.width - EDGE_PADDING,
633
- y: defaultY
634
- });
635
- }
636
- }
637
- }, [enablePositionPersistence, centerOnboarding, animatedPosition, bubbleSize.width, bubbleSize.height, safeAreaInsets.top, safeAreaInsets.bottom, screenWidth, screenHeight]);
638
-
639
- // Toggle hide/show function
640
- const toggleHideShow = (0, _react.useCallback)(() => {
641
- const currentX = getAnimatedValue(animatedPosition.x);
642
- const currentY = getAnimatedValue(animatedPosition.y);
643
-
644
- // Check if the menu is visually off-screen (more than half hidden to the right)
645
- // This handles edge cases where isHidden state might be out of sync with actual position
646
- const isVisuallyOffScreen = currentX > screenWidth - bubbleSize.width / 2;
647
- if (isHidden || isVisuallyOffScreen) {
648
- // Show the bubble - restore to saved position or default visible position
649
- let targetX;
650
- let targetY;
651
- if (savedPositionRef.current && savedPositionRef.current.x < screenWidth - bubbleSize.width / 2) {
652
- // Restore to the saved position only if it's a valid visible position
653
- targetX = savedPositionRef.current.x;
654
- targetY = savedPositionRef.current.y;
655
- } else {
656
- // Default visible position if no saved position or saved position is off-screen
657
- targetX = screenWidth - bubbleSize.width - EDGE_PADDING;
658
- targetY = currentY;
659
- }
660
-
661
- // User explicitly wants the menu visible (overrides pushToSide)
662
- isPushedBySideRef.current = false;
663
- userWantsVisibleRef.current = true;
664
- setIsHidden(false);
665
- _reactNative.Animated.timing(animatedPosition, {
666
- toValue: {
667
- x: targetX,
668
- y: targetY
669
- },
670
- duration: 200,
671
- useNativeDriver: false
672
- }).start(() => {
673
- savePosition(targetX, targetY);
674
- });
675
- } else {
676
- // Hide the bubble - save current position before hiding
677
- savedPositionRef.current = {
678
- x: currentX,
679
- y: currentY
680
- };
681
-
682
- // User explicitly wants the menu hidden
683
- userWantsVisibleRef.current = false;
684
- const hiddenX = screenWidth - VISIBLE_HANDLE_WIDTH;
685
- setIsHidden(true);
686
- _reactNative.Animated.timing(animatedPosition, {
687
- toValue: {
688
- x: hiddenX,
689
- y: currentY
690
- },
691
- duration: 200,
692
- useNativeDriver: false
693
- }).start(() => {
694
- savePosition(hiddenX, currentY);
695
- });
696
- }
697
- }, [animatedPosition, isHidden, bubbleSize.width, savePosition, screenWidth]);
698
- const handleDragStart = (0, _react.useCallback)(() => {
699
- setIsDragging(true);
700
- }, []);
701
- const handleDragEnd = (0, _react.useCallback)(finalPosition => {
702
- let {
703
- x: currentX,
704
- y: currentY
705
- } = finalPosition;
706
-
707
- // Check if bubble is more than 50% over the right edge
708
- const bubbleMidpoint = currentX + bubbleSize.width / 2;
709
- const shouldHide = bubbleMidpoint > screenWidth;
710
- if (shouldHide) {
711
- // Animate to hidden position (only grabber visible)
712
- const hiddenX = screenWidth - VISIBLE_HANDLE_WIDTH;
713
- setIsHidden(true);
714
-
715
- // Update savedPositionRef to preserve the Y position when dragging while hidden
716
- // This ensures that when showing, the bubble restores to the new Y position
717
- if (savedPositionRef.current) {
718
- savedPositionRef.current.y = currentY;
719
- } else {
720
- // If no saved position exists, create one with default visible X
721
- savedPositionRef.current = {
722
- x: screenWidth - bubbleSize.width - EDGE_PADDING,
723
- y: currentY
724
- };
725
- }
726
- _reactNative.Animated.timing(animatedPosition, {
727
- toValue: {
728
- x: hiddenX,
729
- y: currentY
730
- },
731
- duration: 200,
732
- useNativeDriver: false
733
- }).start(() => {
734
- savePosition(hiddenX, currentY);
735
- });
736
- } else {
737
- // Check if we're in hidden state and user is pulling it back
738
- if (isHidden && currentX < screenWidth - VISIBLE_HANDLE_WIDTH - 10) {
739
- setIsHidden(false);
740
- }
741
-
742
- // Update saved position if bubble is in visible area (not hidden)
743
- if (currentX < screenWidth - bubbleSize.width / 2) {
744
- savedPositionRef.current = {
745
- x: currentX,
746
- y: currentY
747
- };
748
- }
749
- savePosition(currentX, currentY);
750
- }
751
- setIsDragging(false);
752
- }, [animatedPosition, bubbleSize.width, isHidden, savePosition, screenWidth]);
753
-
754
- // Stable styles
755
- const bubbleStyle = (0, _react.useMemo)(() => ({
756
- position: "absolute",
757
- zIndex: 1001,
758
- transform: animatedPosition.getTranslateTransform()
759
- }), [animatedPosition]);
760
-
761
- // Get minimized tools context and app host for restoration
25
+ /** The DevTools content adapter. All floating behavior lives in shared UI. */
26
+ function FloatingTools(props) {
762
27
  const {
763
28
  minimizedTools
764
29
  } = (0, _MinimizedToolsContext.useMinimizedTools)();
765
30
  const {
766
- restore: restoreInAppHost
31
+ restore
767
32
  } = (0, _AppHost.useAppHost)();
768
-
769
- // Check if minimized tools are showing (for seamless connection)
770
- const hasMinimizedTools = minimizedTools.length > 0;
771
- const containerStyle = {
772
- flexDirection: "row",
773
- alignItems: "center",
774
- backgroundColor: _sharedUi.buoyColors.card,
775
- // When minimized tools showing, remove top-left radius so they connect seamlessly
776
- borderTopLeftRadius: hasMinimizedTools ? 0 : 6,
777
- borderTopRightRadius: 6,
778
- borderBottomLeftRadius: 6,
779
- borderBottomRightRadius: 6,
780
- // Use individual border widths when minimized tools are showing
781
- // Always show top border when dragging for visual feedback
782
- borderLeftWidth: isDragging ? 2 : 1,
783
- borderRightWidth: isDragging ? 2 : 1,
784
- borderBottomWidth: isDragging ? 2 : 1,
785
- borderTopWidth: isDragging ? 2 : hasMinimizedTools ? 0 : 1,
786
- borderColor: isDragging ? _floatingToolsCore.floatingToolsColors.dragActive : _sharedUi.buoyColors.textMuted + "66",
787
- overflow: "visible",
788
- elevation: 8,
789
- shadowColor: isDragging ? _floatingToolsCore.floatingToolsColors.dragActive + "99" : "#000",
790
- shadowOffset: {
791
- width: 0,
792
- height: isDragging ? 6 : 4
793
- },
794
- shadowOpacity: isDragging ? 0.6 : 0.3,
795
- shadowRadius: isDragging ? 12 : 8
796
- };
797
- const dragHandleStyle = {
798
- paddingHorizontal: 6,
799
- paddingVertical: 6,
800
- backgroundColor: _sharedUi.buoyColors.textMuted + "1A",
801
- alignItems: "center",
802
- justifyContent: "center",
803
- width: 32,
804
- borderRightWidth: 1,
805
- borderRightColor: _sharedUi.buoyColors.textMuted + "66",
806
- // Remove top-left radius when minimized tools are showing
807
- borderTopLeftRadius: hasMinimizedTools ? 0 : undefined
808
- };
809
- const contentStyle = {
810
- flexDirection: "row",
811
- alignItems: "center",
812
- gap: 6,
813
- paddingRight: 8
814
- };
815
-
816
- // Compose actions row with automatic dividers
817
- const actions = (0, _react.useMemo)(() => interleaveWithDividers(_react.Children.toArray(children)), [children]);
818
-
819
- // Handle restore from minimized tools stack
820
- const handleMinimizedRestore = (0, _react.useCallback)(tool => {
821
- restoreInAppHost(tool.instanceId, tool.modalState);
822
- }, [restoreInAppHost]);
823
-
824
- // Width for the minimized tools stack - match the drag handle width
825
- const minimizedStackWidth = 32;
826
-
827
- // Don't render until position is initialized (when persistence enabled)
828
- // This prevents the bubble from appearing at {0,0} before position loads
829
- if (enablePositionPersistence && !isPositionInitialized) {
830
- return null;
831
- }
832
- return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Animated.View, {
833
- style: bubbleStyle,
834
- children: /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
835
- style: {
836
- overflow: "visible"
837
- },
838
- children: [minimizedTools.length > 0 && /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
839
- style: {
840
- position: "absolute",
841
- bottom: "100%",
842
- left: 0,
843
- width: minimizedStackWidth,
844
- overflow: "visible",
845
- zIndex: 1001
846
- },
847
- children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_MinimizedToolsStack.MinimizedToolsStack, {
848
- onRestore: handleMinimizedRestore,
849
- containerWidth: minimizedStackWidth
850
- })
851
- }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
852
- hitSlop: {
853
- top: 10,
854
- bottom: 10,
855
- left: 10,
856
- right: 10
857
- },
858
- style: containerStyle,
859
- onLayout: event => {
860
- const {
861
- width,
862
- height
863
- } = event.nativeEvent.layout;
864
- setBubbleSize({
865
- width,
866
- height
867
- });
868
- },
869
- children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.DraggableHeader, {
870
- position: animatedPosition,
871
- testID: "buoy-floating-grip",
872
- accessibilityLabel: "Move dev tools bubble",
873
- onDragStart: handleDragStart,
874
- onDragEnd: handleDragEnd,
875
- onTap: toggleHideShow,
876
- containerBounds: {
877
- width: screenWidth,
878
- height: screenHeight
879
- },
880
- elementSize: bubbleSize,
881
- minPosition: {
882
- x: safeAreaInsets.left,
883
- y: safeAreaInsets.top + EDGE_PADDING
884
- },
885
- style: dragHandleStyle,
886
- enabled: true,
887
- maxOverflowX: bubbleSize.width,
888
- children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.GripVerticalIcon, {
889
- size: 12,
890
- color: _sharedUi.buoyColors.textSecondary + "CC"
891
- })
892
- }), /*#__PURE__*/(0, _jsxRuntime.jsx)(FloatingToolsContext.Provider, {
893
- value: {
894
- isDragging
895
- },
896
- children: /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
897
- style: contentStyle,
898
- children: [showEnvironmentSelector && environment && availableEnvironments && onEnvironmentSwitch && /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.EnvironmentSelectorInline, {
899
- currentEnvironment: environment,
900
- availableEnvironments: availableEnvironments,
901
- onSelect: onEnvironmentSwitch
902
- }), actions]
903
- })
904
- })]
905
- })]
906
- })
33
+ const onRestore = (0, _react.useCallback)(tool => restore(tool.instanceId, tool.modalState), [restore]);
34
+ return /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.FloatingTool, {
35
+ ...props,
36
+ hasMinimizedStack: minimizedTools.length > 0,
37
+ minimizedStack: minimizedTools.length ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_MinimizedToolsStack.MinimizedToolsStack, {
38
+ onRestore: onRestore,
39
+ containerWidth: 32
40
+ }) : undefined
907
41
  });
908
42
  }