andy-widget-react 2.1.1 → 2.2.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/README.md +1 -1
- package/dist/index.cjs +191 -363
- package/dist/index.cjs.map +1 -1
- package/dist/index.esm.js +191 -363
- package/dist/index.esm.js.map +1 -1
- package/dist/types/types/index.d.ts +1 -10
- package/package.json +2 -2
- package/src/AndyChat.tsx +49 -36
- package/src/components/MessageInput.tsx +3 -2
- package/src/components/MessageList.tsx +5 -3
- package/src/hooks/useAndyChat.ts +30 -13
- package/src/types/index.ts +1 -11
|
@@ -54,6 +54,7 @@ export interface Message {
|
|
|
54
54
|
isError?: boolean;
|
|
55
55
|
isHuman?: boolean;
|
|
56
56
|
isWaiting?: boolean;
|
|
57
|
+
isStreaming?: boolean;
|
|
57
58
|
}
|
|
58
59
|
export interface UserInfo {
|
|
59
60
|
name: string;
|
|
@@ -180,13 +181,3 @@ export interface HostClassOptions {
|
|
|
180
181
|
theme?: 'light' | 'dark' | 'auto';
|
|
181
182
|
orientation?: 'left' | 'right';
|
|
182
183
|
}
|
|
183
|
-
export interface KeyboardListenerCallback {
|
|
184
|
-
(state: {
|
|
185
|
-
isOpen: boolean;
|
|
186
|
-
height: number;
|
|
187
|
-
}): void;
|
|
188
|
-
}
|
|
189
|
-
export interface KeyboardDetector {
|
|
190
|
-
addListener: (callback: KeyboardListenerCallback) => void;
|
|
191
|
-
removeListener: (callback: KeyboardListenerCallback) => void;
|
|
192
|
-
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "andy-widget-react",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.1",
|
|
4
4
|
"description": "Official React Widget for embeddable Andy agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.cjs",
|
|
@@ -67,6 +67,6 @@
|
|
|
67
67
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
68
68
|
},
|
|
69
69
|
"dependencies": {
|
|
70
|
-
"andy-widget-core": "2.
|
|
70
|
+
"andy-widget-core": "2.2.1"
|
|
71
71
|
}
|
|
72
72
|
}
|
package/src/AndyChat.tsx
CHANGED
|
@@ -19,8 +19,6 @@ import {
|
|
|
19
19
|
getFooterUrl,
|
|
20
20
|
getDefaultBubbleLabel,
|
|
21
21
|
getUiStrings,
|
|
22
|
-
getKeyboardDetector,
|
|
23
|
-
updateKeyboardHeight,
|
|
24
22
|
isMobileDevice
|
|
25
23
|
} from 'andy-widget-core'
|
|
26
24
|
|
|
@@ -95,6 +93,8 @@ const AndyChat: React.FC<AndyChatProps> = ({
|
|
|
95
93
|
const [isOpen, setIsOpen] = useState(initialOpen)
|
|
96
94
|
const [messages, setMessages] = useState<Message[]>([])
|
|
97
95
|
const [isLoading, setIsLoading] = useState(false)
|
|
96
|
+
const [replyStarted, setReplyStarted] = useState(false)
|
|
97
|
+
const activeReplyRef = useRef<AbortController | null>(null)
|
|
98
98
|
const [sessionId, setSessionId] = useState(() => generateSessionId())
|
|
99
99
|
const handoffRef = useRef<ReturnType<typeof createBrowserHandoff>>(null)
|
|
100
100
|
const pendingHandoffIdsRef = useRef<string[]>([])
|
|
@@ -118,6 +118,9 @@ const AndyChat: React.FC<AndyChatProps> = ({
|
|
|
118
118
|
handoffRef.current?.stop()
|
|
119
119
|
const handoff = createBrowserHandoff(config.id, config.bootstrap, projection => {
|
|
120
120
|
if (projection.offline) return
|
|
121
|
+
const paused = projection.control === 'requested' || projection.control === 'human'
|
|
122
|
+
if (paused) activeReplyRef.current?.abort()
|
|
123
|
+
else if (activeReplyRef.current) return
|
|
121
124
|
setSessionId(projection.identity.sessionId)
|
|
122
125
|
pendingHandoffIdsRef.current = projection.pendingProjectionIds || []
|
|
123
126
|
const projectedMessages = projection.messages.map(message => ({ id: message.id, content: message.content, sender: message.role === 'user' ? 'user' as const : 'bot' as const, timestamp: new Date(message.createdAt).toISOString(), isHuman: message.sentByHuman }))
|
|
@@ -128,8 +131,10 @@ const AndyChat: React.FC<AndyChatProps> = ({
|
|
|
128
131
|
: projectedMessages)
|
|
129
132
|
})
|
|
130
133
|
handoffRef.current = handoff
|
|
134
|
+
if (handoff) setSessionId(handoff.identity.sessionId)
|
|
131
135
|
return () => {
|
|
132
|
-
|
|
136
|
+
activeReplyRef.current?.abort()
|
|
137
|
+
handoffRef.current?.stop()
|
|
133
138
|
handoffRef.current = null
|
|
134
139
|
}
|
|
135
140
|
}, [config?.bootstrap, config?.id])
|
|
@@ -185,24 +190,7 @@ const AndyChat: React.FC<AndyChatProps> = ({
|
|
|
185
190
|
|
|
186
191
|
setShadowReady(true)
|
|
187
192
|
|
|
188
|
-
const keyboardDetector = getKeyboardDetector()
|
|
189
|
-
const keyboardListener = ({ isOpen: kbOpen, height }: { isOpen: boolean; height: number }) => {
|
|
190
|
-
updateKeyboardHeight(containerRef.current, height)
|
|
191
|
-
|
|
192
|
-
const chatWindow = shadowRef.current?.querySelector('.andy-chat-window')
|
|
193
|
-
if (chatWindow) {
|
|
194
|
-
if (kbOpen && height > 0) {
|
|
195
|
-
chatWindow.classList.add('andy-chat-window--keyboard-open')
|
|
196
|
-
} else {
|
|
197
|
-
chatWindow.classList.remove('andy-chat-window--keyboard-open')
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
keyboardDetector.addListener(keyboardListener)
|
|
203
|
-
|
|
204
193
|
return () => {
|
|
205
|
-
keyboardDetector.removeListener(keyboardListener)
|
|
206
194
|
shadowRef.current = null
|
|
207
195
|
portalRef.current = null
|
|
208
196
|
setShadowReady(false)
|
|
@@ -487,7 +475,13 @@ const AndyChat: React.FC<AndyChatProps> = ({
|
|
|
487
475
|
}, [isCollectingUserInfo, messages])
|
|
488
476
|
|
|
489
477
|
const handleNewChat = () => {
|
|
490
|
-
|
|
478
|
+
activeReplyRef.current?.abort()
|
|
479
|
+
activeReplyRef.current = null
|
|
480
|
+
setIsLoading(false)
|
|
481
|
+
setReplyStarted(false)
|
|
482
|
+
handoffRef.current = handoffRef.current?.restart() || null
|
|
483
|
+
pendingHandoffIdsRef.current = []
|
|
484
|
+
setSessionId(handoffRef.current?.identity.sessionId || generateSessionId())
|
|
491
485
|
|
|
492
486
|
if (config?.welcomeMessage) {
|
|
493
487
|
const greeting = createGreetingMessage(config)
|
|
@@ -513,12 +507,14 @@ const AndyChat: React.FC<AndyChatProps> = ({
|
|
|
513
507
|
const sendUserMessage = useCallback(
|
|
514
508
|
async (rawMessageText: string, options: { hidePredefined?: boolean } = {}) => {
|
|
515
509
|
const messageText = typeof rawMessageText === 'string' ? rawMessageText.trim() : ''
|
|
516
|
-
if (!messageText) {
|
|
510
|
+
if (!messageText || activeReplyRef.current) {
|
|
517
511
|
return
|
|
518
512
|
}
|
|
519
513
|
|
|
520
514
|
const { hidePredefined = true } = options
|
|
521
515
|
const timestamp = Date.now()
|
|
516
|
+
const controller = new AbortController()
|
|
517
|
+
activeReplyRef.current = controller
|
|
522
518
|
|
|
523
519
|
const userMessage: Message = {
|
|
524
520
|
id: timestamp,
|
|
@@ -535,19 +531,29 @@ const AndyChat: React.FC<AndyChatProps> = ({
|
|
|
535
531
|
|
|
536
532
|
try {
|
|
537
533
|
setIsLoading(true)
|
|
538
|
-
|
|
534
|
+
setReplyStarted(false)
|
|
535
|
+
const showReply = (content: string, isStreaming: boolean) => {
|
|
536
|
+
if (controller.signal.aborted) return
|
|
537
|
+
setReplyStarted(true)
|
|
538
|
+
const reply: Message = { id: timestamp + 1, content, sender: 'bot', timestamp: new Date(timestamp).toISOString(), isStreaming }
|
|
539
|
+
setMessages(prev => prev.some(message => message.id === reply.id)
|
|
540
|
+
? prev.map(message => message.id === reply.id ? reply : message)
|
|
541
|
+
: [...prev, reply])
|
|
542
|
+
}
|
|
543
|
+
const response = await sendMessageToAPI(embedId!, sessionId, messageText, {
|
|
544
|
+
signal: controller.signal,
|
|
545
|
+
onText: text => showReply(text, true)
|
|
546
|
+
})
|
|
547
|
+
if (controller.signal.aborted) return
|
|
539
548
|
if (response.status === 'waiting') {
|
|
540
|
-
setMessages(prev =>
|
|
549
|
+
setMessages(prev => {
|
|
550
|
+
const visible = prev.filter(message => message.id !== timestamp + 1)
|
|
551
|
+
return visible.some(message => message.isWaiting) ? visible : [...visible, { id: 'handoff-waiting', content: strings.handoffWaiting, sender: 'bot', timestamp: new Date().toISOString(), isWaiting: true }]
|
|
552
|
+
})
|
|
541
553
|
return
|
|
542
554
|
}
|
|
543
555
|
|
|
544
|
-
|
|
545
|
-
id: timestamp + 1,
|
|
546
|
-
content: response.text,
|
|
547
|
-
sender: 'bot',
|
|
548
|
-
timestamp: new Date().toISOString()
|
|
549
|
-
}
|
|
550
|
-
setMessages(prev => [...prev, botMessage])
|
|
556
|
+
showReply(response.text, false)
|
|
551
557
|
|
|
552
558
|
if (config?.collectUserInfo?.enabled && !userInfoCollected && !isCollectingUserInfo) {
|
|
553
559
|
setPendingMessage(messageText)
|
|
@@ -563,6 +569,7 @@ const AndyChat: React.FC<AndyChatProps> = ({
|
|
|
563
569
|
setMessages(prev => [...prev, formMessage])
|
|
564
570
|
}
|
|
565
571
|
} catch {
|
|
572
|
+
if (controller.signal.aborted) return
|
|
566
573
|
console.error('AndyChat: Message request failed.')
|
|
567
574
|
const errorMessage: Message = {
|
|
568
575
|
id: timestamp + 3,
|
|
@@ -571,9 +578,13 @@ const AndyChat: React.FC<AndyChatProps> = ({
|
|
|
571
578
|
timestamp: new Date().toISOString(),
|
|
572
579
|
isError: true
|
|
573
580
|
}
|
|
574
|
-
setMessages(prev => [...prev, errorMessage])
|
|
581
|
+
setMessages(prev => [...prev.filter(message => message.id !== timestamp + 1), errorMessage])
|
|
575
582
|
} finally {
|
|
576
|
-
|
|
583
|
+
if (activeReplyRef.current === controller) {
|
|
584
|
+
activeReplyRef.current = null
|
|
585
|
+
setIsLoading(false)
|
|
586
|
+
setReplyStarted(false)
|
|
587
|
+
}
|
|
577
588
|
}
|
|
578
589
|
},
|
|
579
590
|
[config, embedId, sessionId, userInfoCollected, isCollectingUserInfo]
|
|
@@ -963,7 +974,7 @@ const AndyChat: React.FC<AndyChatProps> = ({
|
|
|
963
974
|
</span>
|
|
964
975
|
</div>
|
|
965
976
|
<div className='andy-message__content'>
|
|
966
|
-
<div dangerouslySetInnerHTML={{ __html: parseMarkdown(message.content) }} />
|
|
977
|
+
{message.isStreaming ? <div className='andy-message__stream'>{message.content}</div> : <div dangerouslySetInnerHTML={{ __html: parseMarkdown(message.content) }} />}
|
|
967
978
|
</div>
|
|
968
979
|
</div>
|
|
969
980
|
)}
|
|
@@ -978,7 +989,7 @@ const AndyChat: React.FC<AndyChatProps> = ({
|
|
|
978
989
|
)
|
|
979
990
|
})}
|
|
980
991
|
|
|
981
|
-
{isLoading && (
|
|
992
|
+
{isLoading && !replyStarted && (
|
|
982
993
|
<div className='andy-message-container andy-message-container--bot andy-message-container--typing'>
|
|
983
994
|
<div className='andy-message andy-message--bot andy-message--typing'>
|
|
984
995
|
<div className='andy-typing-indicator'>
|
|
@@ -1026,6 +1037,7 @@ const AndyChat: React.FC<AndyChatProps> = ({
|
|
|
1026
1037
|
placeholder={config ? strings.inputPlaceholder : '...'}
|
|
1027
1038
|
aria-label={strings.messageInput}
|
|
1028
1039
|
onKeyDown={e => {
|
|
1040
|
+
if (e.key === 'Enter' && activeReplyRef.current) { e.preventDefault(); return }
|
|
1029
1041
|
if (e.key === 'Enter' && (e.target as HTMLInputElement).value.trim()) {
|
|
1030
1042
|
handleSendMessage((e.target as HTMLInputElement).value)
|
|
1031
1043
|
;(e.target as HTMLInputElement).value = ''
|
|
@@ -1039,6 +1051,7 @@ const AndyChat: React.FC<AndyChatProps> = ({
|
|
|
1039
1051
|
id='andy-send-button'
|
|
1040
1052
|
aria-label={strings.sendMessage}
|
|
1041
1053
|
onClick={() => {
|
|
1054
|
+
if (activeReplyRef.current) return
|
|
1042
1055
|
const input = shadowRef.current?.getElementById(
|
|
1043
1056
|
'andy-message-input'
|
|
1044
1057
|
) as HTMLInputElement | null
|
|
@@ -1047,7 +1060,7 @@ const AndyChat: React.FC<AndyChatProps> = ({
|
|
|
1047
1060
|
input.value = ''
|
|
1048
1061
|
}
|
|
1049
1062
|
}}
|
|
1050
|
-
disabled={isCollectingUserInfo || !config}
|
|
1063
|
+
disabled={isCollectingUserInfo || isLoading || !config}
|
|
1051
1064
|
>
|
|
1052
1065
|
<svg
|
|
1053
1066
|
className='andy-input__button-icon'
|
|
@@ -25,14 +25,15 @@ const MessageInput: React.FC<MessageInputProps> = ({
|
|
|
25
25
|
|
|
26
26
|
try {
|
|
27
27
|
setIsSubmitting(true)
|
|
28
|
-
await onSendMessage(trimmedMessage)
|
|
29
28
|
setMessage('')
|
|
29
|
+
await onSendMessage(trimmedMessage)
|
|
30
30
|
|
|
31
31
|
if (inputRef.current) {
|
|
32
32
|
inputRef.current.focus()
|
|
33
33
|
}
|
|
34
34
|
} catch (error) {
|
|
35
35
|
console.error('Error sending message:', error)
|
|
36
|
+
setMessage(current => current || trimmedMessage)
|
|
36
37
|
} finally {
|
|
37
38
|
setIsSubmitting(false)
|
|
38
39
|
}
|
|
@@ -61,7 +62,7 @@ const MessageInput: React.FC<MessageInputProps> = ({
|
|
|
61
62
|
onChange={handleInputChange}
|
|
62
63
|
onKeyDown={handleKeyDown}
|
|
63
64
|
placeholder={placeholder}
|
|
64
|
-
disabled={
|
|
65
|
+
disabled={disabled}
|
|
65
66
|
maxLength={1000}
|
|
66
67
|
autoComplete='off'
|
|
67
68
|
id='andy-input'
|
|
@@ -17,7 +17,7 @@ const MessageComponent: React.FC<MessageProps> = ({ message, botName }) => {
|
|
|
17
17
|
|
|
18
18
|
const messageClasses = ['andy-message', `andy-message--${message.sender}`].filter(Boolean).join(' ')
|
|
19
19
|
|
|
20
|
-
const messageContent = isBot ? parseMarkdown(message.content) : message.content
|
|
20
|
+
const messageContent = isBot && !message.isStreaming ? parseMarkdown(message.content) : message.content
|
|
21
21
|
|
|
22
22
|
if (isBot) {
|
|
23
23
|
return (
|
|
@@ -46,7 +46,9 @@ const MessageComponent: React.FC<MessageProps> = ({ message, botName }) => {
|
|
|
46
46
|
<span className='andy-bot-header__name'>{botName || 'Andy bot'}</span>
|
|
47
47
|
</div>
|
|
48
48
|
<div className={messageClasses}>
|
|
49
|
-
|
|
49
|
+
{message.isStreaming
|
|
50
|
+
? <div className='andy-message__content andy-message__stream'>{message.content}</div>
|
|
51
|
+
: <div className='andy-message__content' dangerouslySetInnerHTML={{ __html: messageContent }} />}
|
|
50
52
|
</div>
|
|
51
53
|
</div>
|
|
52
54
|
)
|
|
@@ -105,7 +107,7 @@ const MessageList: React.FC<MessageListProps> = ({ messages, isLoading, logoUrl,
|
|
|
105
107
|
/>
|
|
106
108
|
))}
|
|
107
109
|
|
|
108
|
-
{isLoading && <TypingIndicator />}
|
|
110
|
+
{isLoading && !messages.some(message => message.isStreaming) && <TypingIndicator />}
|
|
109
111
|
|
|
110
112
|
{messages.length === 0 && !isLoading && (
|
|
111
113
|
<div className='andy-empty-state'>
|
package/src/hooks/useAndyChat.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useCallback, useRef } from 'react'
|
|
1
|
+
import { useState, useCallback, useRef, useEffect } from 'react'
|
|
2
2
|
import {
|
|
3
3
|
fetchChatbotConfig,
|
|
4
4
|
generateSessionId,
|
|
@@ -31,6 +31,12 @@ export const useAndyChat = (embedId: string): UseAndyChatReturn => {
|
|
|
31
31
|
const [showingUserInfoForm, setShowingUserInfoForm] = useState(false)
|
|
32
32
|
const [welcomePopupShown, setWelcomePopupShown] = useState(false)
|
|
33
33
|
|
|
34
|
+
const activeReplyRef = useRef<AbortController | null>(null)
|
|
35
|
+
useEffect(() => () => {
|
|
36
|
+
activeReplyRef.current?.abort()
|
|
37
|
+
activeReplyRef.current = null
|
|
38
|
+
}, [embedId])
|
|
39
|
+
|
|
34
40
|
const welcomeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
35
41
|
const userInfoTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
36
42
|
|
|
@@ -127,10 +133,12 @@ export const useAndyChat = (embedId: string): UseAndyChatReturn => {
|
|
|
127
133
|
|
|
128
134
|
const sendMessage = useCallback(
|
|
129
135
|
async (messageText: string) => {
|
|
130
|
-
if (!messageText.trim() || !config || !sessionId) {
|
|
136
|
+
if (!messageText.trim() || !config || !sessionId || activeReplyRef.current) {
|
|
131
137
|
return
|
|
132
138
|
}
|
|
133
139
|
|
|
140
|
+
const controller = new AbortController()
|
|
141
|
+
activeReplyRef.current = controller
|
|
134
142
|
const userMessage: Message = {
|
|
135
143
|
id: Date.now(),
|
|
136
144
|
content: messageText,
|
|
@@ -140,21 +148,26 @@ export const useAndyChat = (embedId: string): UseAndyChatReturn => {
|
|
|
140
148
|
|
|
141
149
|
setMessages(prev => [...prev, userMessage])
|
|
142
150
|
|
|
151
|
+
const replyId = Date.now() + 1
|
|
152
|
+
const showReply = (content: string, isStreaming: boolean) => {
|
|
153
|
+
if (controller.signal.aborted) return
|
|
154
|
+
const reply: Message = { id: replyId, content, sender: 'bot', timestamp: new Date().toISOString(), isStreaming }
|
|
155
|
+
setMessages(prev => prev.some(message => message.id === replyId)
|
|
156
|
+
? prev.map(message => message.id === replyId ? reply : message)
|
|
157
|
+
: [...prev, reply])
|
|
158
|
+
}
|
|
143
159
|
try {
|
|
144
|
-
const response = await sendMessageToAPI(embedId, sessionId, messageText)
|
|
145
|
-
if (
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
content: response.text || 'I apologize, but I encountered an error processing your request.',
|
|
150
|
-
sender: 'bot',
|
|
151
|
-
timestamp: new Date().toISOString()
|
|
160
|
+
const response = await sendMessageToAPI(embedId, sessionId, messageText, { signal: controller.signal, onText: text => showReply(text, true) })
|
|
161
|
+
if (controller.signal.aborted) return
|
|
162
|
+
if (response.status === 'waiting') {
|
|
163
|
+
setMessages(prev => prev.filter(message => message.id !== replyId))
|
|
164
|
+
return response
|
|
152
165
|
}
|
|
153
|
-
|
|
154
|
-
setMessages(prev => [...prev, botMessage])
|
|
166
|
+
showReply(response.text, false)
|
|
155
167
|
|
|
156
168
|
return response
|
|
157
169
|
} catch (err) {
|
|
170
|
+
if (controller.signal.aborted) return
|
|
158
171
|
console.error('Error sending message:', err)
|
|
159
172
|
|
|
160
173
|
const errorMessage: Message = {
|
|
@@ -165,8 +178,10 @@ export const useAndyChat = (embedId: string): UseAndyChatReturn => {
|
|
|
165
178
|
isError: true
|
|
166
179
|
}
|
|
167
180
|
|
|
168
|
-
setMessages(prev => [...prev, errorMessage])
|
|
181
|
+
setMessages(prev => [...prev.filter(message => message.id !== replyId), errorMessage])
|
|
169
182
|
throw err
|
|
183
|
+
} finally {
|
|
184
|
+
if (activeReplyRef.current === controller) activeReplyRef.current = null
|
|
170
185
|
}
|
|
171
186
|
},
|
|
172
187
|
[embedId, sessionId, config]
|
|
@@ -220,6 +235,8 @@ export const useAndyChat = (embedId: string): UseAndyChatReturn => {
|
|
|
220
235
|
)
|
|
221
236
|
|
|
222
237
|
const startNewSession = useCallback(() => {
|
|
238
|
+
activeReplyRef.current?.abort()
|
|
239
|
+
activeReplyRef.current = null
|
|
223
240
|
const newSessionId = generateSessionId()
|
|
224
241
|
setSessionId(newSessionId)
|
|
225
242
|
|
package/src/types/index.ts
CHANGED
|
@@ -66,6 +66,7 @@ export interface Message {
|
|
|
66
66
|
isError?: boolean
|
|
67
67
|
isHuman?: boolean
|
|
68
68
|
isWaiting?: boolean
|
|
69
|
+
isStreaming?: boolean
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
// User info for form submission
|
|
@@ -221,14 +222,3 @@ export interface HostClassOptions {
|
|
|
221
222
|
theme?: 'light' | 'dark' | 'auto'
|
|
222
223
|
orientation?: 'left' | 'right'
|
|
223
224
|
}
|
|
224
|
-
|
|
225
|
-
// Keyboard detector listener callback
|
|
226
|
-
export interface KeyboardListenerCallback {
|
|
227
|
-
(state: { isOpen: boolean; height: number }): void
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// Keyboard detector interface
|
|
231
|
-
export interface KeyboardDetector {
|
|
232
|
-
addListener: (callback: KeyboardListenerCallback) => void
|
|
233
|
-
removeListener: (callback: KeyboardListenerCallback) => void
|
|
234
|
-
}
|