@bytexbyte/nxtlinq-ai-agent-web-development 0.1.1

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 (149) hide show
  1. package/dist/context/NxtlinqAgentContext.d.ts +12 -0
  2. package/dist/context/NxtlinqAgentContext.d.ts.map +1 -0
  3. package/dist/context/NxtlinqAgentContext.js +33 -0
  4. package/dist/createNxtlinqAgent.d.ts +9 -0
  5. package/dist/createNxtlinqAgent.d.ts.map +1 -0
  6. package/dist/createNxtlinqAgent.js +19 -0
  7. package/dist/hooks/useNxtlinqAgent.d.ts +18 -0
  8. package/dist/hooks/useNxtlinqAgent.d.ts.map +1 -0
  9. package/dist/hooks/useNxtlinqAgent.js +23 -0
  10. package/dist/hooks/useNxtlinqVoice.d.ts +21 -0
  11. package/dist/hooks/useNxtlinqVoice.d.ts.map +1 -0
  12. package/dist/hooks/useNxtlinqVoice.js +75 -0
  13. package/dist/index.d.ts +12 -0
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +9 -0
  16. package/dist/legacy/api/nxtlinq-api.d.ts +8 -0
  17. package/dist/legacy/api/nxtlinq-api.d.ts.map +1 -0
  18. package/dist/legacy/api/nxtlinq-api.js +13 -0
  19. package/dist/legacy/api/voice.d.ts +11 -0
  20. package/dist/legacy/api/voice.d.ts.map +1 -0
  21. package/dist/legacy/api/voice.js +26 -0
  22. package/dist/legacy/core/lib/messageHistory.d.ts +2 -0
  23. package/dist/legacy/core/lib/messageHistory.d.ts.map +1 -0
  24. package/dist/legacy/core/lib/messageHistory.js +1 -0
  25. package/dist/legacy/core/lib/textToSpeech.d.ts +14 -0
  26. package/dist/legacy/core/lib/textToSpeech.d.ts.map +1 -0
  27. package/dist/legacy/core/lib/textToSpeech.js +82 -0
  28. package/dist/legacy/core/lib/useDraggable.d.ts +15 -0
  29. package/dist/legacy/core/lib/useDraggable.d.ts.map +1 -0
  30. package/dist/legacy/core/lib/useDraggable.js +158 -0
  31. package/dist/legacy/core/lib/useLocalStorage.d.ts +11 -0
  32. package/dist/legacy/core/lib/useLocalStorage.d.ts.map +1 -0
  33. package/dist/legacy/core/lib/useLocalStorage.js +83 -0
  34. package/dist/legacy/core/lib/useResizable.d.ts +17 -0
  35. package/dist/legacy/core/lib/useResizable.d.ts.map +1 -0
  36. package/dist/legacy/core/lib/useResizable.js +203 -0
  37. package/dist/legacy/core/lib/useSessionStorage.d.ts +11 -0
  38. package/dist/legacy/core/lib/useSessionStorage.d.ts.map +1 -0
  39. package/dist/legacy/core/lib/useSessionStorage.js +37 -0
  40. package/dist/legacy/core/lib/useSpeechToTextFromMic/helper.d.ts +26 -0
  41. package/dist/legacy/core/lib/useSpeechToTextFromMic/helper.d.ts.map +1 -0
  42. package/dist/legacy/core/lib/useSpeechToTextFromMic/helper.js +102 -0
  43. package/dist/legacy/core/lib/useSpeechToTextFromMic/index.d.ts +16 -0
  44. package/dist/legacy/core/lib/useSpeechToTextFromMic/index.d.ts.map +1 -0
  45. package/dist/legacy/core/lib/useSpeechToTextFromMic/index.js +92 -0
  46. package/dist/legacy/core/lib/useVoiceMode.d.ts +32 -0
  47. package/dist/legacy/core/lib/useVoiceMode.d.ts.map +1 -0
  48. package/dist/legacy/core/lib/useVoiceMode.js +373 -0
  49. package/dist/legacy/core/metakeepClient.d.ts +4 -0
  50. package/dist/legacy/core/metakeepClient.d.ts.map +1 -0
  51. package/dist/legacy/core/metakeepClient.js +10 -0
  52. package/dist/legacy/core/utils/aitUtils.d.ts +31 -0
  53. package/dist/legacy/core/utils/aitUtils.d.ts.map +1 -0
  54. package/dist/legacy/core/utils/aitUtils.js +35 -0
  55. package/dist/legacy/core/utils/ethersUtils.d.ts +8 -0
  56. package/dist/legacy/core/utils/ethersUtils.d.ts.map +1 -0
  57. package/dist/legacy/core/utils/ethersUtils.js +19 -0
  58. package/dist/legacy/core/utils/index.d.ts +3 -0
  59. package/dist/legacy/core/utils/index.d.ts.map +1 -0
  60. package/dist/legacy/core/utils/index.js +4 -0
  61. package/dist/legacy/core/utils/notificationUtils.d.ts +29 -0
  62. package/dist/legacy/core/utils/notificationUtils.d.ts.map +1 -0
  63. package/dist/legacy/core/utils/notificationUtils.js +47 -0
  64. package/dist/legacy/core/utils/urlUtils.d.ts +25 -0
  65. package/dist/legacy/core/utils/urlUtils.d.ts.map +1 -0
  66. package/dist/legacy/core/utils/urlUtils.js +135 -0
  67. package/dist/legacy/core/utils/walletTextUtils.d.ts +14 -0
  68. package/dist/legacy/core/utils/walletTextUtils.d.ts.map +1 -0
  69. package/dist/legacy/core/utils/walletTextUtils.js +23 -0
  70. package/dist/legacy/core/utils/walletUtils.d.ts +10 -0
  71. package/dist/legacy/core/utils/walletUtils.d.ts.map +1 -0
  72. package/dist/legacy/core/utils/walletUtils.js +38 -0
  73. package/dist/legacy/index.d.ts +19 -0
  74. package/dist/legacy/index.d.ts.map +1 -0
  75. package/dist/legacy/index.js +16 -0
  76. package/dist/ports/createWebPlatformPorts.d.ts +13 -0
  77. package/dist/ports/createWebPlatformPorts.d.ts.map +1 -0
  78. package/dist/ports/createWebPlatformPorts.js +25 -0
  79. package/dist/utils/fileToAttachment.d.ts +4 -0
  80. package/dist/utils/fileToAttachment.d.ts.map +1 -0
  81. package/dist/utils/fileToAttachment.js +28 -0
  82. package/dist/voice/useVoiceSilenceCommit.d.ts +11 -0
  83. package/dist/voice/useVoiceSilenceCommit.d.ts.map +1 -0
  84. package/dist/voice/useVoiceSilenceCommit.js +68 -0
  85. package/dist/voice/useVoiceTranscriptMessages.d.ts +16 -0
  86. package/dist/voice/useVoiceTranscriptMessages.d.ts.map +1 -0
  87. package/dist/voice/useVoiceTranscriptMessages.js +134 -0
  88. package/dist/voice/useWsRealtimeAudio.d.ts +18 -0
  89. package/dist/voice/useWsRealtimeAudio.d.ts.map +1 -0
  90. package/dist/voice/useWsRealtimeAudio.js +115 -0
  91. package/dist/voice/voiceMicConstants.d.ts +4 -0
  92. package/dist/voice/voiceMicConstants.d.ts.map +1 -0
  93. package/dist/voice/voiceMicConstants.js +10 -0
  94. package/dist/voice/ws/BrowserWsPcmPlayer.d.ts +23 -0
  95. package/dist/voice/ws/BrowserWsPcmPlayer.d.ts.map +1 -0
  96. package/dist/voice/ws/BrowserWsPcmPlayer.js +138 -0
  97. package/dist/voice/ws/BrowserWsPcmRecorder.d.ts +19 -0
  98. package/dist/voice/ws/BrowserWsPcmRecorder.d.ts.map +1 -0
  99. package/dist/voice/ws/BrowserWsPcmRecorder.js +76 -0
  100. package/dist/voice/ws/float32ToPcm16.d.ts +2 -0
  101. package/dist/voice/ws/float32ToPcm16.d.ts.map +1 -0
  102. package/dist/voice/ws/float32ToPcm16.js +8 -0
  103. package/dist/voice/ws/voiceSilenceConstants.d.ts +5 -0
  104. package/dist/voice/ws/voiceSilenceConstants.d.ts.map +1 -0
  105. package/dist/voice/ws/voiceSilenceConstants.js +4 -0
  106. package/dist/voice/ws/wsRealtimeConstants.d.ts +2 -0
  107. package/dist/voice/ws/wsRealtimeConstants.d.ts.map +1 -0
  108. package/dist/voice/ws/wsRealtimeConstants.js +1 -0
  109. package/dist/webAgentDefaults.d.ts +9 -0
  110. package/dist/webAgentDefaults.d.ts.map +1 -0
  111. package/dist/webAgentDefaults.js +9 -0
  112. package/package.json +55 -0
  113. package/src/context/NxtlinqAgentContext.tsx +79 -0
  114. package/src/createNxtlinqAgent.ts +36 -0
  115. package/src/hooks/useNxtlinqAgent.ts +73 -0
  116. package/src/hooks/useNxtlinqVoice.ts +143 -0
  117. package/src/index.ts +84 -0
  118. package/src/legacy/api/nxtlinq-api.ts +32 -0
  119. package/src/legacy/api/voice.ts +72 -0
  120. package/src/legacy/core/lib/messageHistory.ts +6 -0
  121. package/src/legacy/core/lib/textToSpeech.ts +127 -0
  122. package/src/legacy/core/lib/useDraggable.ts +193 -0
  123. package/src/legacy/core/lib/useLocalStorage.ts +89 -0
  124. package/src/legacy/core/lib/useResizable.ts +256 -0
  125. package/src/legacy/core/lib/useSessionStorage.ts +43 -0
  126. package/src/legacy/core/lib/useSpeechToTextFromMic/helper.ts +132 -0
  127. package/src/legacy/core/lib/useSpeechToTextFromMic/index.ts +126 -0
  128. package/src/legacy/core/lib/useVoiceMode.ts +407 -0
  129. package/src/legacy/core/metakeepClient.ts +12 -0
  130. package/src/legacy/core/utils/aitUtils.ts +55 -0
  131. package/src/legacy/core/utils/ethersUtils.ts +24 -0
  132. package/src/legacy/core/utils/index.ts +5 -0
  133. package/src/legacy/core/utils/notificationUtils.ts +64 -0
  134. package/src/legacy/core/utils/urlUtils.ts +160 -0
  135. package/src/legacy/core/utils/walletTextUtils.ts +26 -0
  136. package/src/legacy/core/utils/walletUtils.ts +53 -0
  137. package/src/legacy/index.ts +35 -0
  138. package/src/ports/createWebPlatformPorts.ts +44 -0
  139. package/src/utils/fileToAttachment.ts +32 -0
  140. package/src/voice/useVoiceSilenceCommit.ts +84 -0
  141. package/src/voice/useVoiceTranscriptMessages.ts +184 -0
  142. package/src/voice/useWsRealtimeAudio.ts +141 -0
  143. package/src/voice/voiceMicConstants.ts +13 -0
  144. package/src/voice/ws/BrowserWsPcmPlayer.ts +139 -0
  145. package/src/voice/ws/BrowserWsPcmRecorder.ts +83 -0
  146. package/src/voice/ws/float32ToPcm16.ts +8 -0
  147. package/src/voice/ws/voiceSilenceConstants.ts +4 -0
  148. package/src/voice/ws/wsRealtimeConstants.ts +1 -0
  149. package/src/webAgentDefaults.ts +12 -0
@@ -0,0 +1,193 @@
1
+ import { useState, useRef, useCallback, useEffect } from 'react';
2
+ import useLocalStorage from './useLocalStorage';
3
+
4
+ interface Position {
5
+ x: number;
6
+ y: number;
7
+ }
8
+
9
+ const DEFAULT_POSITION: Position = { x: 0, y: 0 }; // Default position, will be calculated
10
+ const VIEWPORT_MARGIN = 20; // Keep space from viewport edges
11
+
12
+ export const useDraggable = (dimensions: { width: number; height: number }) => {
13
+ const dragState = useRef<{
14
+ startX: number;
15
+ startY: number;
16
+ startPosition: Position;
17
+ }>({
18
+ startX: 0,
19
+ startY: 0,
20
+ startPosition: DEFAULT_POSITION
21
+ });
22
+
23
+ // Keep track of current position for saving
24
+ const currentPositionRef = useRef<Position>(DEFAULT_POSITION);
25
+
26
+ // Track if we've already loaded from localStorage to prevent loops
27
+ const hasLoadedFromStorage = useRef(false);
28
+
29
+ // Clamp position to keep window within viewport
30
+ const clampPosition = useCallback((pos: Position, dims: { width: number; height: number }): Position => {
31
+ if (typeof window === 'undefined') {
32
+ return pos;
33
+ }
34
+ const maxX = Math.max(VIEWPORT_MARGIN, window.innerWidth - dims.width - VIEWPORT_MARGIN);
35
+ const maxY = Math.max(VIEWPORT_MARGIN, window.innerHeight - dims.height - VIEWPORT_MARGIN);
36
+ return {
37
+ x: Math.max(VIEWPORT_MARGIN, Math.min(pos.x, maxX)),
38
+ y: Math.max(VIEWPORT_MARGIN, Math.min(pos.y, maxY))
39
+ };
40
+ }, []);
41
+
42
+ // Calculate initial position from bottom/right (default behavior)
43
+ const calculateInitialPosition = useCallback((): Position => {
44
+ if (typeof window === 'undefined') {
45
+ return DEFAULT_POSITION;
46
+ }
47
+ // Default position: bottom-right corner with margin
48
+ const initialPos = {
49
+ x: window.innerWidth - dimensions.width - VIEWPORT_MARGIN,
50
+ y: window.innerHeight - dimensions.height - VIEWPORT_MARGIN
51
+ };
52
+ return clampPosition(initialPos, dimensions);
53
+ }, [dimensions.width, dimensions.height, clampPosition]);
54
+
55
+ // Load position from localStorage or use default
56
+ const [savedPosition, setSavedPosition, isPositionInitialized] = useLocalStorage<Position | null>(
57
+ 'chat-window-position',
58
+ null
59
+ );
60
+
61
+ const [position, setPosition] = useState<Position>(() => {
62
+ // Always start with calculated initial position
63
+ // Will be updated when savedPosition loads from localStorage
64
+ const initialPos = calculateInitialPosition();
65
+ currentPositionRef.current = initialPos;
66
+ return initialPos;
67
+ });
68
+
69
+ // Update position when saved position loads from localStorage (only once)
70
+ useEffect(() => {
71
+ if (isPositionInitialized && !hasLoadedFromStorage.current) {
72
+ if (savedPosition) {
73
+ // Use saved position
74
+ const clampedPos = clampPosition(savedPosition, dimensions);
75
+ currentPositionRef.current = clampedPos;
76
+ setPosition(clampedPos);
77
+ }
78
+ // Mark as loaded regardless of whether we have saved position
79
+ hasLoadedFromStorage.current = true;
80
+ }
81
+ }, [isPositionInitialized, savedPosition, dimensions, clampPosition]);
82
+
83
+ // Update position when dimensions change (to keep window in viewport, but only after initial load)
84
+ useEffect(() => {
85
+ if (!isPositionInitialized || !hasLoadedFromStorage.current) return;
86
+
87
+ setPosition((current) => {
88
+ const clampedPosition = clampPosition(current, dimensions);
89
+ if (clampedPosition.x !== current.x || clampedPosition.y !== current.y) {
90
+ currentPositionRef.current = clampedPosition;
91
+ return clampedPosition;
92
+ }
93
+ return current;
94
+ });
95
+ }, [dimensions.width, dimensions.height, isPositionInitialized, clampPosition]);
96
+
97
+ const [isDragging, setIsDragging] = useState(false);
98
+
99
+ const handleDragStart = (event: React.PointerEvent<HTMLDivElement>) => {
100
+ // Don't start dragging if clicking on buttons or interactive elements
101
+ const target = event.target as HTMLElement;
102
+ if (
103
+ target.tagName === 'BUTTON' ||
104
+ target.closest('button') ||
105
+ target.closest('[role="button"]') ||
106
+ target.closest('input') ||
107
+ target.closest('select') ||
108
+ target.closest('textarea')
109
+ ) {
110
+ return;
111
+ }
112
+
113
+ event.preventDefault();
114
+ event.stopPropagation();
115
+ dragState.current = {
116
+ startX: event.clientX,
117
+ startY: event.clientY,
118
+ startPosition: position
119
+ };
120
+ setIsDragging(true);
121
+ document.body.style.userSelect = 'none';
122
+ document.body.style.cursor = 'grabbing';
123
+ };
124
+
125
+ // Handle pointer move and up events
126
+ useEffect(() => {
127
+ if (!isDragging) {
128
+ return;
129
+ }
130
+
131
+ const handlePointerMove = (event: PointerEvent) => {
132
+ const deltaX = event.clientX - dragState.current.startX;
133
+ const deltaY = event.clientY - dragState.current.startY;
134
+ const newPosition: Position = {
135
+ x: dragState.current.startPosition.x + deltaX,
136
+ y: dragState.current.startPosition.y + deltaY
137
+ };
138
+ const clampedPos = clampPosition(newPosition, dimensions);
139
+ currentPositionRef.current = clampedPos;
140
+ setPosition(clampedPos);
141
+ };
142
+
143
+ const handlePointerUp = () => {
144
+ setIsDragging(false);
145
+ document.body.style.userSelect = '';
146
+ document.body.style.cursor = '';
147
+ // Save position to localStorage (only after initial load)
148
+ if (hasLoadedFromStorage.current) {
149
+ setSavedPosition(currentPositionRef.current);
150
+ }
151
+ };
152
+
153
+ document.addEventListener('pointermove', handlePointerMove);
154
+ document.addEventListener('pointerup', handlePointerUp);
155
+
156
+ return () => {
157
+ document.removeEventListener('pointermove', handlePointerMove);
158
+ document.removeEventListener('pointerup', handlePointerUp);
159
+ document.body.style.userSelect = '';
160
+ document.body.style.cursor = '';
161
+ };
162
+ }, [isDragging, clampPosition, dimensions, setSavedPosition]);
163
+
164
+ // Handle window resize to keep window in viewport
165
+ useEffect(() => {
166
+ const handleResize = () => {
167
+ setPosition((current) => {
168
+ const clamped = clampPosition(current, dimensions);
169
+ currentPositionRef.current = clamped;
170
+ return clamped;
171
+ });
172
+ };
173
+ window.addEventListener('resize', handleResize);
174
+ return () => window.removeEventListener('resize', handleResize);
175
+ }, [clampPosition, dimensions]);
176
+
177
+ const updatePosition = useCallback((newPosition: Position) => {
178
+ const clamped = clampPosition(newPosition, dimensions);
179
+ currentPositionRef.current = clamped;
180
+ setPosition(clamped);
181
+ // Save position to localStorage when updated (only after initial load)
182
+ if (hasLoadedFromStorage.current) {
183
+ setSavedPosition(clamped);
184
+ }
185
+ }, [clampPosition, dimensions, setSavedPosition]);
186
+
187
+ return {
188
+ position,
189
+ handleDragStart,
190
+ isDragging,
191
+ updatePosition
192
+ };
193
+ };
@@ -0,0 +1,89 @@
1
+ import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react';
2
+
3
+ /**
4
+ * Custom hook for managing localStorage with React state and cross-tab synchronization
5
+ * Automatically syncs changes across browser tabs using StorageEvent
6
+ *
7
+ * @param key - The key to store the value under in localStorage
8
+ * @param defaultValue - The default value to use if no value is stored
9
+ * @returns [storedValue, setStoredValue, isInitialized]
10
+ */
11
+ export default function useLocalStorage<T>(key: string, defaultValue: T): [T, Dispatch<SetStateAction<T>>, boolean] {
12
+ const [storedValue, setStoredValue] = useState<T>(defaultValue);
13
+ const [isInitialized, setIsInitialized] = useState(false);
14
+ // Ref to track if we're currently processing a storage event (to prevent loops)
15
+ // Note: StorageEvent only fires in OTHER tabs, not the current tab, so this ref
16
+ // is mainly used to prevent any edge cases
17
+ const isProcessingStorageEventRef = useRef(false);
18
+
19
+ // Initialize from localStorage
20
+ useEffect(() => {
21
+ try {
22
+ const storageValue = localStorage.getItem(key);
23
+ if (storageValue !== null) {
24
+ const parsed = JSON.parse(storageValue) as T;
25
+ setStoredValue(parsed);
26
+ }
27
+ } catch (error) {
28
+ console.warn(`Error reading from localStorage key "${key}":`, error);
29
+ }
30
+
31
+ setIsInitialized(true);
32
+ }, [key]);
33
+
34
+ // Save to localStorage when value changes
35
+ useEffect(() => {
36
+ if (!isInitialized || isProcessingStorageEventRef.current) return;
37
+
38
+ try {
39
+ const serialized = JSON.stringify(storedValue);
40
+ const currentValue = localStorage.getItem(key);
41
+
42
+ // Only write if the value actually changed (avoid unnecessary writes)
43
+ if (currentValue !== serialized) {
44
+ localStorage.setItem(key, serialized);
45
+ }
46
+ } catch (error) {
47
+ console.warn(`Error writing to localStorage key "${key}":`, error);
48
+ }
49
+ }, [storedValue, isInitialized, key]);
50
+
51
+ // Listen for storage changes from other tabs (cross-tab synchronization)
52
+ useEffect(() => {
53
+ const handleStorageChange = (e: StorageEvent) => {
54
+ // Only handle events for this key and ignore events from the current tab
55
+ if (e.key !== key || e.storageArea !== localStorage) {
56
+ return;
57
+ }
58
+
59
+ // StorageEvent only fires in OTHER tabs, not the tab that made the change
60
+ // So we can safely update the state here
61
+ try {
62
+ isProcessingStorageEventRef.current = true;
63
+
64
+ if (e.newValue !== null) {
65
+ const newValue = JSON.parse(e.newValue) as T;
66
+ setStoredValue(newValue);
67
+ } else {
68
+ // If newValue is null, it means the item was removed
69
+ setStoredValue(defaultValue);
70
+ }
71
+ } catch (error) {
72
+ console.warn(`Error parsing storage event for key "${key}":`, error);
73
+ } finally {
74
+ // Reset the flag after a brief delay to allow state updates to complete
75
+ setTimeout(() => {
76
+ isProcessingStorageEventRef.current = false;
77
+ }, 0);
78
+ }
79
+ };
80
+
81
+ window.addEventListener('storage', handleStorageChange);
82
+
83
+ return () => {
84
+ window.removeEventListener('storage', handleStorageChange);
85
+ };
86
+ }, [key, defaultValue]);
87
+
88
+ return [storedValue, setStoredValue, isInitialized];
89
+ }
@@ -0,0 +1,256 @@
1
+ import { useState, useRef, useCallback, useEffect, RefObject } from 'react';
2
+ import useLocalStorage from './useLocalStorage';
3
+
4
+ // Resizable chat window defaults
5
+ const DEFAULT_WIDTH = 500;
6
+ const DEFAULT_HEIGHT = 600;
7
+ const ASPECT_RATIO = DEFAULT_WIDTH / DEFAULT_HEIGHT;
8
+ const MIN_WIDTH = 320;
9
+ const MIN_HEIGHT = 380;
10
+ const VIEWPORT_MARGIN = 40; // Keep space from viewport edges
11
+ const MOBILE_BREAKPOINT = 768;
12
+ const MOBILE_EDGE_MARGIN = 12;
13
+
14
+ const isMobileViewport = (): boolean =>
15
+ typeof window !== 'undefined' && window.innerWidth <= MOBILE_BREAKPOINT;
16
+
17
+ const getMobileFullscreenDimensions = (): Dimensions => ({
18
+ width: Math.max(MIN_WIDTH, window.innerWidth - MOBILE_EDGE_MARGIN * 2),
19
+ height: Math.max(MIN_HEIGHT, window.innerHeight - MOBILE_EDGE_MARGIN * 2),
20
+ });
21
+
22
+ export type ResizeCorner = 'nw' | 'ne' | 'sw' | 'se';
23
+
24
+ interface Position {
25
+ x: number;
26
+ y: number;
27
+ }
28
+
29
+ interface ResizeState {
30
+ corner: ResizeCorner;
31
+ /** Window rect anchors at pointer-down (screen coords) */
32
+ anchorTL: Position;
33
+ anchorBR: Position;
34
+ }
35
+
36
+ interface Dimensions {
37
+ width: number;
38
+ height: number;
39
+ }
40
+
41
+ export const useResizable = (currentPositionRef?: RefObject<Position>) => {
42
+ const resizeState = useRef<ResizeState | null>(null);
43
+
44
+ const [positionAdjustment, setPositionAdjustment] = useState<Position | null>(null);
45
+
46
+ const [savedDimensions, setSavedDimensions, isDimensionsInitialized] = useLocalStorage<Dimensions | null>(
47
+ 'chat-window-dimensions',
48
+ null
49
+ );
50
+
51
+ const [isResizing, setIsResizing] = useState(false);
52
+
53
+ const clampDimensions = useCallback((width: number, height: number): Dimensions => {
54
+ if (typeof window === 'undefined') {
55
+ return { width, height };
56
+ }
57
+ if (isMobileViewport()) {
58
+ return getMobileFullscreenDimensions();
59
+ }
60
+ const maxWidth = Math.max(MIN_WIDTH, window.innerWidth - VIEWPORT_MARGIN);
61
+ const maxHeight = Math.max(MIN_HEIGHT, window.innerHeight - VIEWPORT_MARGIN);
62
+ const clampedWidth = Math.min(Math.max(width, MIN_WIDTH), maxWidth);
63
+ let clampedHeight = clampedWidth / ASPECT_RATIO;
64
+ if (clampedHeight > maxHeight) {
65
+ clampedHeight = maxHeight;
66
+ const adjustedWidth = clampedHeight * ASPECT_RATIO;
67
+ return { width: adjustedWidth, height: clampedHeight };
68
+ }
69
+ if (clampedHeight < MIN_HEIGHT) {
70
+ clampedHeight = MIN_HEIGHT;
71
+ const adjustedWidth = clampedHeight * ASPECT_RATIO;
72
+ return { width: adjustedWidth, height: clampedHeight };
73
+ }
74
+ return { width: clampedWidth, height: clampedHeight };
75
+ }, []);
76
+
77
+ const calculateDimensions = useCallback((savedDims: Dimensions | null): Dimensions => {
78
+ if (typeof window === 'undefined') {
79
+ return { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT };
80
+ }
81
+ const baseDimensions = savedDims || { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT };
82
+ return clampDimensions(baseDimensions.width, baseDimensions.height);
83
+ }, [clampDimensions]);
84
+
85
+ const [dimensions, setDimensions] = useState<Dimensions>(() => {
86
+ if (typeof window === 'undefined') {
87
+ return { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT };
88
+ }
89
+ if (isMobileViewport()) {
90
+ return getMobileFullscreenDimensions();
91
+ }
92
+ const maxWidth = Math.max(MIN_WIDTH, window.innerWidth - VIEWPORT_MARGIN);
93
+ const maxHeight = Math.max(MIN_HEIGHT, window.innerHeight - VIEWPORT_MARGIN);
94
+ let width = Math.min(Math.max(DEFAULT_WIDTH, MIN_WIDTH), maxWidth);
95
+ let height = width / ASPECT_RATIO;
96
+ if (height > maxHeight) {
97
+ height = maxHeight;
98
+ width = height * ASPECT_RATIO;
99
+ }
100
+ if (height < MIN_HEIGHT) {
101
+ height = MIN_HEIGHT;
102
+ width = height * ASPECT_RATIO;
103
+ }
104
+ return { width, height };
105
+ });
106
+
107
+ const hasLoadedFromStorage = useRef(false);
108
+
109
+ useEffect(() => {
110
+ if (isDimensionsInitialized && !hasLoadedFromStorage.current) {
111
+ const newDimensions = calculateDimensions(savedDimensions);
112
+ setDimensions(newDimensions);
113
+ hasLoadedFromStorage.current = true;
114
+ }
115
+ }, [isDimensionsInitialized, savedDimensions, calculateDimensions]);
116
+
117
+ const handleResizeStart = useCallback(
118
+ (corner: ResizeCorner) => (event: React.PointerEvent<HTMLDivElement>) => {
119
+ event.preventDefault();
120
+ event.stopPropagation();
121
+ const currentPos = currentPositionRef?.current;
122
+ if (!currentPos) {
123
+ return;
124
+ }
125
+ const anchorTL: Position = { x: currentPos.x, y: currentPos.y };
126
+ const anchorBR: Position = {
127
+ x: currentPos.x + dimensions.width,
128
+ y: currentPos.y + dimensions.height
129
+ };
130
+ resizeState.current = { corner, anchorTL, anchorBR };
131
+ setIsResizing(true);
132
+ setPositionAdjustment(null);
133
+ document.body.style.userSelect = 'none';
134
+ },
135
+ [currentPositionRef, dimensions.width, dimensions.height]
136
+ );
137
+
138
+ useEffect(() => {
139
+ if (!isResizing) {
140
+ return;
141
+ }
142
+
143
+ const handlePointerMove = (event: PointerEvent) => {
144
+ const state = resizeState.current;
145
+ if (!state) {
146
+ return;
147
+ }
148
+
149
+ const { corner, anchorTL, anchorBR } = state;
150
+ const anchorBL: Position = { x: anchorTL.x, y: anchorBR.y };
151
+ const anchorTR: Position = { x: anchorBR.x, y: anchorTL.y };
152
+
153
+ let targetFromX: number;
154
+ let targetFromY: number;
155
+
156
+ switch (corner) {
157
+ case 'nw': {
158
+ const deltaX = anchorBR.x - event.clientX;
159
+ const deltaY = anchorBR.y - event.clientY;
160
+ targetFromX = deltaX;
161
+ targetFromY = deltaY * ASPECT_RATIO;
162
+ break;
163
+ }
164
+ case 'se': {
165
+ targetFromX = event.clientX - anchorTL.x;
166
+ targetFromY = (event.clientY - anchorTL.y) * ASPECT_RATIO;
167
+ break;
168
+ }
169
+ case 'ne': {
170
+ targetFromX = event.clientX - anchorBL.x;
171
+ targetFromY = (anchorBL.y - event.clientY) * ASPECT_RATIO;
172
+ break;
173
+ }
174
+ case 'sw': {
175
+ targetFromX = anchorTR.x - event.clientX;
176
+ targetFromY = (event.clientY - anchorTR.y) * ASPECT_RATIO;
177
+ break;
178
+ }
179
+ default:
180
+ return;
181
+ }
182
+
183
+ const nextWidth = (targetFromX + targetFromY) / 2;
184
+ const newDimensions = clampDimensions(nextWidth, nextWidth / ASPECT_RATIO);
185
+
186
+ let newPos: Position;
187
+ switch (corner) {
188
+ case 'nw':
189
+ newPos = {
190
+ x: anchorBR.x - newDimensions.width,
191
+ y: anchorBR.y - newDimensions.height
192
+ };
193
+ break;
194
+ case 'se':
195
+ newPos = { x: anchorTL.x, y: anchorTL.y };
196
+ break;
197
+ case 'ne':
198
+ newPos = {
199
+ x: anchorBL.x,
200
+ y: anchorBL.y - newDimensions.height
201
+ };
202
+ break;
203
+ case 'sw':
204
+ newPos = {
205
+ x: anchorTR.x - newDimensions.width,
206
+ y: anchorTR.y
207
+ };
208
+ break;
209
+ default:
210
+ newPos = anchorTL;
211
+ }
212
+
213
+ setDimensions(newDimensions);
214
+ setPositionAdjustment(newPos);
215
+ };
216
+
217
+ const handlePointerUp = () => {
218
+ setIsResizing(false);
219
+ resizeState.current = null;
220
+ setPositionAdjustment(null);
221
+ document.body.style.userSelect = '';
222
+ };
223
+
224
+ document.addEventListener('pointermove', handlePointerMove);
225
+ document.addEventListener('pointerup', handlePointerUp);
226
+
227
+ return () => {
228
+ document.removeEventListener('pointermove', handlePointerMove);
229
+ document.removeEventListener('pointerup', handlePointerUp);
230
+ document.body.style.userSelect = '';
231
+ };
232
+ }, [clampDimensions, isResizing]);
233
+
234
+ useEffect(() => {
235
+ if (!isResizing && isDimensionsInitialized && hasLoadedFromStorage.current) {
236
+ setSavedDimensions(dimensions);
237
+ }
238
+ }, [dimensions, isResizing, isDimensionsInitialized, setSavedDimensions]);
239
+
240
+ useEffect(() => {
241
+ const handleResize = () => {
242
+ setDimensions((current) => {
243
+ const clamped = clampDimensions(current.width, current.height);
244
+ return clamped;
245
+ });
246
+ };
247
+ window.addEventListener('resize', handleResize);
248
+ return () => window.removeEventListener('resize', handleResize);
249
+ }, [clampDimensions]);
250
+
251
+ return {
252
+ dimensions,
253
+ handleResizeStart,
254
+ positionAdjustment
255
+ };
256
+ };
@@ -0,0 +1,43 @@
1
+ import { Dispatch, SetStateAction, useEffect, useState } from 'react';
2
+
3
+ /**
4
+ * Custom hook for managing sessionStorage with React state
5
+ * Similar to useLocalStorage but uses sessionStorage instead
6
+ *
7
+ * @param key - The key to store the value under in sessionStorage
8
+ * @param defaultValue - The default value to use if no value is stored
9
+ * @returns [storedValue, setStoredValue, isInitialized]
10
+ */
11
+ export default function useSessionStorage<T>(
12
+ key: string,
13
+ defaultValue: T
14
+ ): [T, Dispatch<SetStateAction<T>>, boolean] {
15
+ const [storedValue, setStoredValue] = useState<T>(defaultValue);
16
+ const [isInitialized, setIsInitialized] = useState(false);
17
+
18
+ useEffect(() => {
19
+ try {
20
+ const storageValue = sessionStorage.getItem(key);
21
+ if (storageValue !== null) {
22
+ setStoredValue(JSON.parse(storageValue));
23
+ }
24
+ } catch (error) {
25
+ console.warn(`Error reading from sessionStorage key "${key}":`, error);
26
+ // If there's an error, keep the default value
27
+ }
28
+
29
+ setIsInitialized(true);
30
+ }, [key]);
31
+
32
+ useEffect(() => {
33
+ if (isInitialized) {
34
+ try {
35
+ sessionStorage.setItem(key, JSON.stringify(storedValue));
36
+ } catch (error) {
37
+ console.warn(`Error writing to sessionStorage key "${key}":`, error);
38
+ }
39
+ }
40
+ }, [storedValue, isInitialized, key]);
41
+
42
+ return [storedValue, setStoredValue, isInitialized];
43
+ }