@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.
- package/dist/context/NxtlinqAgentContext.d.ts +12 -0
- package/dist/context/NxtlinqAgentContext.d.ts.map +1 -0
- package/dist/context/NxtlinqAgentContext.js +33 -0
- package/dist/createNxtlinqAgent.d.ts +9 -0
- package/dist/createNxtlinqAgent.d.ts.map +1 -0
- package/dist/createNxtlinqAgent.js +19 -0
- package/dist/hooks/useNxtlinqAgent.d.ts +18 -0
- package/dist/hooks/useNxtlinqAgent.d.ts.map +1 -0
- package/dist/hooks/useNxtlinqAgent.js +23 -0
- package/dist/hooks/useNxtlinqVoice.d.ts +21 -0
- package/dist/hooks/useNxtlinqVoice.d.ts.map +1 -0
- package/dist/hooks/useNxtlinqVoice.js +75 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/legacy/api/nxtlinq-api.d.ts +8 -0
- package/dist/legacy/api/nxtlinq-api.d.ts.map +1 -0
- package/dist/legacy/api/nxtlinq-api.js +13 -0
- package/dist/legacy/api/voice.d.ts +11 -0
- package/dist/legacy/api/voice.d.ts.map +1 -0
- package/dist/legacy/api/voice.js +26 -0
- package/dist/legacy/core/lib/messageHistory.d.ts +2 -0
- package/dist/legacy/core/lib/messageHistory.d.ts.map +1 -0
- package/dist/legacy/core/lib/messageHistory.js +1 -0
- package/dist/legacy/core/lib/textToSpeech.d.ts +14 -0
- package/dist/legacy/core/lib/textToSpeech.d.ts.map +1 -0
- package/dist/legacy/core/lib/textToSpeech.js +82 -0
- package/dist/legacy/core/lib/useDraggable.d.ts +15 -0
- package/dist/legacy/core/lib/useDraggable.d.ts.map +1 -0
- package/dist/legacy/core/lib/useDraggable.js +158 -0
- package/dist/legacy/core/lib/useLocalStorage.d.ts +11 -0
- package/dist/legacy/core/lib/useLocalStorage.d.ts.map +1 -0
- package/dist/legacy/core/lib/useLocalStorage.js +83 -0
- package/dist/legacy/core/lib/useResizable.d.ts +17 -0
- package/dist/legacy/core/lib/useResizable.d.ts.map +1 -0
- package/dist/legacy/core/lib/useResizable.js +203 -0
- package/dist/legacy/core/lib/useSessionStorage.d.ts +11 -0
- package/dist/legacy/core/lib/useSessionStorage.d.ts.map +1 -0
- package/dist/legacy/core/lib/useSessionStorage.js +37 -0
- package/dist/legacy/core/lib/useSpeechToTextFromMic/helper.d.ts +26 -0
- package/dist/legacy/core/lib/useSpeechToTextFromMic/helper.d.ts.map +1 -0
- package/dist/legacy/core/lib/useSpeechToTextFromMic/helper.js +102 -0
- package/dist/legacy/core/lib/useSpeechToTextFromMic/index.d.ts +16 -0
- package/dist/legacy/core/lib/useSpeechToTextFromMic/index.d.ts.map +1 -0
- package/dist/legacy/core/lib/useSpeechToTextFromMic/index.js +92 -0
- package/dist/legacy/core/lib/useVoiceMode.d.ts +32 -0
- package/dist/legacy/core/lib/useVoiceMode.d.ts.map +1 -0
- package/dist/legacy/core/lib/useVoiceMode.js +373 -0
- package/dist/legacy/core/metakeepClient.d.ts +4 -0
- package/dist/legacy/core/metakeepClient.d.ts.map +1 -0
- package/dist/legacy/core/metakeepClient.js +10 -0
- package/dist/legacy/core/utils/aitUtils.d.ts +31 -0
- package/dist/legacy/core/utils/aitUtils.d.ts.map +1 -0
- package/dist/legacy/core/utils/aitUtils.js +35 -0
- package/dist/legacy/core/utils/ethersUtils.d.ts +8 -0
- package/dist/legacy/core/utils/ethersUtils.d.ts.map +1 -0
- package/dist/legacy/core/utils/ethersUtils.js +19 -0
- package/dist/legacy/core/utils/index.d.ts +3 -0
- package/dist/legacy/core/utils/index.d.ts.map +1 -0
- package/dist/legacy/core/utils/index.js +4 -0
- package/dist/legacy/core/utils/notificationUtils.d.ts +29 -0
- package/dist/legacy/core/utils/notificationUtils.d.ts.map +1 -0
- package/dist/legacy/core/utils/notificationUtils.js +47 -0
- package/dist/legacy/core/utils/urlUtils.d.ts +25 -0
- package/dist/legacy/core/utils/urlUtils.d.ts.map +1 -0
- package/dist/legacy/core/utils/urlUtils.js +135 -0
- package/dist/legacy/core/utils/walletTextUtils.d.ts +14 -0
- package/dist/legacy/core/utils/walletTextUtils.d.ts.map +1 -0
- package/dist/legacy/core/utils/walletTextUtils.js +23 -0
- package/dist/legacy/core/utils/walletUtils.d.ts +10 -0
- package/dist/legacy/core/utils/walletUtils.d.ts.map +1 -0
- package/dist/legacy/core/utils/walletUtils.js +38 -0
- package/dist/legacy/index.d.ts +19 -0
- package/dist/legacy/index.d.ts.map +1 -0
- package/dist/legacy/index.js +16 -0
- package/dist/ports/createWebPlatformPorts.d.ts +13 -0
- package/dist/ports/createWebPlatformPorts.d.ts.map +1 -0
- package/dist/ports/createWebPlatformPorts.js +25 -0
- package/dist/utils/fileToAttachment.d.ts +4 -0
- package/dist/utils/fileToAttachment.d.ts.map +1 -0
- package/dist/utils/fileToAttachment.js +28 -0
- package/dist/voice/useVoiceSilenceCommit.d.ts +11 -0
- package/dist/voice/useVoiceSilenceCommit.d.ts.map +1 -0
- package/dist/voice/useVoiceSilenceCommit.js +68 -0
- package/dist/voice/useVoiceTranscriptMessages.d.ts +16 -0
- package/dist/voice/useVoiceTranscriptMessages.d.ts.map +1 -0
- package/dist/voice/useVoiceTranscriptMessages.js +134 -0
- package/dist/voice/useWsRealtimeAudio.d.ts +18 -0
- package/dist/voice/useWsRealtimeAudio.d.ts.map +1 -0
- package/dist/voice/useWsRealtimeAudio.js +115 -0
- package/dist/voice/voiceMicConstants.d.ts +4 -0
- package/dist/voice/voiceMicConstants.d.ts.map +1 -0
- package/dist/voice/voiceMicConstants.js +10 -0
- package/dist/voice/ws/BrowserWsPcmPlayer.d.ts +23 -0
- package/dist/voice/ws/BrowserWsPcmPlayer.d.ts.map +1 -0
- package/dist/voice/ws/BrowserWsPcmPlayer.js +138 -0
- package/dist/voice/ws/BrowserWsPcmRecorder.d.ts +19 -0
- package/dist/voice/ws/BrowserWsPcmRecorder.d.ts.map +1 -0
- package/dist/voice/ws/BrowserWsPcmRecorder.js +76 -0
- package/dist/voice/ws/float32ToPcm16.d.ts +2 -0
- package/dist/voice/ws/float32ToPcm16.d.ts.map +1 -0
- package/dist/voice/ws/float32ToPcm16.js +8 -0
- package/dist/voice/ws/voiceSilenceConstants.d.ts +5 -0
- package/dist/voice/ws/voiceSilenceConstants.d.ts.map +1 -0
- package/dist/voice/ws/voiceSilenceConstants.js +4 -0
- package/dist/voice/ws/wsRealtimeConstants.d.ts +2 -0
- package/dist/voice/ws/wsRealtimeConstants.d.ts.map +1 -0
- package/dist/voice/ws/wsRealtimeConstants.js +1 -0
- package/dist/webAgentDefaults.d.ts +9 -0
- package/dist/webAgentDefaults.d.ts.map +1 -0
- package/dist/webAgentDefaults.js +9 -0
- package/package.json +55 -0
- package/src/context/NxtlinqAgentContext.tsx +79 -0
- package/src/createNxtlinqAgent.ts +36 -0
- package/src/hooks/useNxtlinqAgent.ts +73 -0
- package/src/hooks/useNxtlinqVoice.ts +143 -0
- package/src/index.ts +84 -0
- package/src/legacy/api/nxtlinq-api.ts +32 -0
- package/src/legacy/api/voice.ts +72 -0
- package/src/legacy/core/lib/messageHistory.ts +6 -0
- package/src/legacy/core/lib/textToSpeech.ts +127 -0
- package/src/legacy/core/lib/useDraggable.ts +193 -0
- package/src/legacy/core/lib/useLocalStorage.ts +89 -0
- package/src/legacy/core/lib/useResizable.ts +256 -0
- package/src/legacy/core/lib/useSessionStorage.ts +43 -0
- package/src/legacy/core/lib/useSpeechToTextFromMic/helper.ts +132 -0
- package/src/legacy/core/lib/useSpeechToTextFromMic/index.ts +126 -0
- package/src/legacy/core/lib/useVoiceMode.ts +407 -0
- package/src/legacy/core/metakeepClient.ts +12 -0
- package/src/legacy/core/utils/aitUtils.ts +55 -0
- package/src/legacy/core/utils/ethersUtils.ts +24 -0
- package/src/legacy/core/utils/index.ts +5 -0
- package/src/legacy/core/utils/notificationUtils.ts +64 -0
- package/src/legacy/core/utils/urlUtils.ts +160 -0
- package/src/legacy/core/utils/walletTextUtils.ts +26 -0
- package/src/legacy/core/utils/walletUtils.ts +53 -0
- package/src/legacy/index.ts +35 -0
- package/src/ports/createWebPlatformPorts.ts +44 -0
- package/src/utils/fileToAttachment.ts +32 -0
- package/src/voice/useVoiceSilenceCommit.ts +84 -0
- package/src/voice/useVoiceTranscriptMessages.ts +184 -0
- package/src/voice/useWsRealtimeAudio.ts +141 -0
- package/src/voice/voiceMicConstants.ts +13 -0
- package/src/voice/ws/BrowserWsPcmPlayer.ts +139 -0
- package/src/voice/ws/BrowserWsPcmRecorder.ts +83 -0
- package/src/voice/ws/float32ToPcm16.ts +8 -0
- package/src/voice/ws/voiceSilenceConstants.ts +4 -0
- package/src/voice/ws/wsRealtimeConstants.ts +1 -0
- 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
|
+
}
|