fix: auto scroll fix, scroll allow user to scroll up during ai response (#1299)
Browse files- app/components/chat/BaseChat.tsx +29 -28
- app/components/chat/Messages.client.tsx +97 -195
- app/lib/hooks/useSnapScroll.ts +139 -36
app/components/chat/BaseChat.tsx
CHANGED
@@ -303,7 +303,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|
303 |
data-chat-visible={showChat}
|
304 |
>
|
305 |
<ClientOnly>{() => <Menu />}</ClientOnly>
|
306 |
-
<div
|
307 |
<div className={classNames(styles.Chat, 'flex flex-col flex-grow lg:min-w-[var(--chat-min-width)] h-full')}>
|
308 |
{!chatStarted && (
|
309 |
<div id="intro" className="mt-[16vh] max-w-chat mx-auto text-center px-4 lg:px-0">
|
@@ -317,39 +317,40 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|
317 |
)}
|
318 |
<div
|
319 |
className={classNames('pt-6 px-2 sm:px-6', {
|
320 |
-
'h-full flex flex-col': chatStarted,
|
321 |
})}
|
322 |
ref={scrollRef}
|
323 |
>
|
324 |
<ClientOnly>
|
325 |
{() => {
|
326 |
return chatStarted ? (
|
327 |
-
<
|
328 |
-
|
329 |
-
|
330 |
-
|
331 |
-
|
332 |
-
|
|
|
|
|
333 |
) : null;
|
334 |
}}
|
335 |
</ClientOnly>
|
336 |
<div
|
337 |
-
className={classNames('flex flex-col gap-4 w-full max-w-chat mx-auto z-prompt
|
338 |
'sticky bottom-2': chatStarted,
|
|
|
339 |
})}
|
340 |
>
|
341 |
-
|
342 |
-
|
343 |
-
|
344 |
-
|
345 |
-
|
346 |
-
|
347 |
-
|
348 |
-
|
349 |
-
|
350 |
-
|
351 |
-
)}
|
352 |
-
</div>
|
353 |
{progressAnnotations && <ProgressCompilation data={progressAnnotations} />}
|
354 |
<div
|
355 |
className={classNames(
|
@@ -583,17 +584,16 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|
583 |
</div>
|
584 |
</div>
|
585 |
</div>
|
586 |
-
|
587 |
-
|
588 |
<div className="flex justify-center gap-2">
|
589 |
<div className="flex items-center gap-2">
|
590 |
{ImportButtons(importChat)}
|
591 |
<GitCloneButton importChat={importChat} className="min-w-[120px]" />
|
592 |
</div>
|
593 |
</div>
|
594 |
-
|
595 |
-
|
596 |
-
ExamplePrompts((event, messageInput) => {
|
597 |
if (isStreaming) {
|
598 |
handleStop?.();
|
599 |
return;
|
@@ -601,8 +601,9 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|
601 |
|
602 |
handleSendMessage?.(event, messageInput);
|
603 |
})}
|
604 |
-
|
605 |
-
|
|
|
606 |
</div>
|
607 |
<ClientOnly>{() => <Workbench chatStarted={chatStarted} isStreaming={isStreaming} />}</ClientOnly>
|
608 |
</div>
|
|
|
303 |
data-chat-visible={showChat}
|
304 |
>
|
305 |
<ClientOnly>{() => <Menu />}</ClientOnly>
|
306 |
+
<div className="flex flex-col lg:flex-row overflow-y-auto w-full h-full">
|
307 |
<div className={classNames(styles.Chat, 'flex flex-col flex-grow lg:min-w-[var(--chat-min-width)] h-full')}>
|
308 |
{!chatStarted && (
|
309 |
<div id="intro" className="mt-[16vh] max-w-chat mx-auto text-center px-4 lg:px-0">
|
|
|
317 |
)}
|
318 |
<div
|
319 |
className={classNames('pt-6 px-2 sm:px-6', {
|
320 |
+
'h-full flex flex-col pb-4 overflow-y-auto': chatStarted,
|
321 |
})}
|
322 |
ref={scrollRef}
|
323 |
>
|
324 |
<ClientOnly>
|
325 |
{() => {
|
326 |
return chatStarted ? (
|
327 |
+
<div className="flex-1 w-full max-w-chat pb-6 mx-auto z-1">
|
328 |
+
<Messages
|
329 |
+
ref={messageRef}
|
330 |
+
className="flex flex-col "
|
331 |
+
messages={messages}
|
332 |
+
isStreaming={isStreaming}
|
333 |
+
/>
|
334 |
+
</div>
|
335 |
) : null;
|
336 |
}}
|
337 |
</ClientOnly>
|
338 |
<div
|
339 |
+
className={classNames('flex flex-col gap-4 w-full max-w-chat mx-auto z-prompt', {
|
340 |
'sticky bottom-2': chatStarted,
|
341 |
+
'position-absolute': chatStarted,
|
342 |
})}
|
343 |
>
|
344 |
+
{actionAlert && (
|
345 |
+
<ChatAlert
|
346 |
+
alert={actionAlert}
|
347 |
+
clearAlert={() => clearAlert?.()}
|
348 |
+
postMessage={(message) => {
|
349 |
+
sendMessage?.({} as any, message);
|
350 |
+
clearAlert?.();
|
351 |
+
}}
|
352 |
+
/>
|
353 |
+
)}
|
|
|
|
|
354 |
{progressAnnotations && <ProgressCompilation data={progressAnnotations} />}
|
355 |
<div
|
356 |
className={classNames(
|
|
|
584 |
</div>
|
585 |
</div>
|
586 |
</div>
|
587 |
+
{!chatStarted && (
|
588 |
+
<div className="flex flex-col justify-center mt-6 gap-5">
|
589 |
<div className="flex justify-center gap-2">
|
590 |
<div className="flex items-center gap-2">
|
591 |
{ImportButtons(importChat)}
|
592 |
<GitCloneButton importChat={importChat} className="min-w-[120px]" />
|
593 |
</div>
|
594 |
</div>
|
595 |
+
|
596 |
+
{ExamplePrompts((event, messageInput) => {
|
|
|
597 |
if (isStreaming) {
|
598 |
handleStop?.();
|
599 |
return;
|
|
|
601 |
|
602 |
handleSendMessage?.(event, messageInput);
|
603 |
})}
|
604 |
+
<StarterTemplates />
|
605 |
+
</div>
|
606 |
+
)}
|
607 |
</div>
|
608 |
<ClientOnly>{() => <Workbench chatStarted={chatStarted} isStreaming={isStreaming} />}</ClientOnly>
|
609 |
</div>
|
app/components/chat/Messages.client.tsx
CHANGED
@@ -1,5 +1,5 @@
|
|
1 |
import type { Message } from 'ai';
|
2 |
-
import
|
3 |
import { classNames } from '~/utils/classNames';
|
4 |
import { AssistantMessage } from './AssistantMessage';
|
5 |
import { UserMessage } from './UserMessage';
|
@@ -10,6 +10,8 @@ import { toast } from 'react-toastify';
|
|
10 |
import WithTooltip from '~/components/ui/Tooltip';
|
11 |
import { useStore } from '@nanostores/react';
|
12 |
import { profileStore } from '~/lib/stores/profile';
|
|
|
|
|
13 |
|
14 |
interface MessagesProps {
|
15 |
id?: string;
|
@@ -18,213 +20,113 @@ interface MessagesProps {
|
|
18 |
messages?: Message[];
|
19 |
}
|
20 |
|
21 |
-
export const Messages =
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
const [isUserInteracting, setIsUserInteracting] = useState(false);
|
27 |
-
const [lastScrollTop, setLastScrollTop] = useState(0);
|
28 |
-
const [shouldAutoScroll, setShouldAutoScroll] = useState(true);
|
29 |
-
const profile = useStore(profileStore);
|
30 |
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
}
|
36 |
-
|
37 |
-
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
38 |
-
const distanceFromBottom = scrollHeight - (scrollTop + clientHeight);
|
39 |
-
|
40 |
-
return distanceFromBottom < 100;
|
41 |
-
};
|
42 |
-
|
43 |
-
const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => {
|
44 |
-
if (!shouldAutoScroll || isUserInteracting) {
|
45 |
-
return;
|
46 |
-
}
|
47 |
-
|
48 |
-
messagesEndRef.current?.scrollIntoView({ behavior });
|
49 |
-
};
|
50 |
-
|
51 |
-
// Handle user interaction and scroll position
|
52 |
-
useEffect(() => {
|
53 |
-
const container = containerRef.current;
|
54 |
-
|
55 |
-
if (!container) {
|
56 |
-
return undefined;
|
57 |
-
}
|
58 |
-
|
59 |
-
const handleInteractionStart = () => {
|
60 |
-
setIsUserInteracting(true);
|
61 |
-
};
|
62 |
-
|
63 |
-
const handleInteractionEnd = () => {
|
64 |
-
if (checkShouldAutoScroll()) {
|
65 |
-
setTimeout(() => setIsUserInteracting(false), 100);
|
66 |
-
}
|
67 |
};
|
68 |
|
69 |
-
const
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
|
76 |
-
|
77 |
-
|
78 |
-
|
|
|
79 |
}
|
80 |
-
|
81 |
-
setLastScrollTop(scrollTop);
|
82 |
};
|
83 |
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
|
111 |
-
|
112 |
-
|
113 |
-
|
114 |
-
|
115 |
-
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
|
127 |
-
const urlId = await forkChat(db, chatId.get()!, messageId);
|
128 |
-
window.location.href = `/chat/${urlId}`;
|
129 |
-
} catch (error) {
|
130 |
-
toast.error('Failed to fork chat: ' + (error as Error).message);
|
131 |
-
}
|
132 |
-
};
|
133 |
-
|
134 |
-
return (
|
135 |
-
<div
|
136 |
-
id={id}
|
137 |
-
ref={(el) => {
|
138 |
-
// Combine refs
|
139 |
-
if (typeof ref === 'function') {
|
140 |
-
ref(el);
|
141 |
-
}
|
142 |
-
|
143 |
-
(containerRef as any).current = el;
|
144 |
-
|
145 |
-
return undefined;
|
146 |
-
}}
|
147 |
-
className={props.className}
|
148 |
-
>
|
149 |
-
{messages.length > 0
|
150 |
-
? messages.map((message, index) => {
|
151 |
-
const { role, content, id: messageId, annotations } = message;
|
152 |
-
const isUserMessage = role === 'user';
|
153 |
-
const isFirst = index === 0;
|
154 |
-
const isLast = index === messages.length - 1;
|
155 |
-
const isHidden = annotations?.includes('hidden');
|
156 |
-
|
157 |
-
if (isHidden) {
|
158 |
-
return <Fragment key={index} />;
|
159 |
-
}
|
160 |
-
|
161 |
-
return (
|
162 |
-
<div
|
163 |
-
key={index}
|
164 |
-
className={classNames('flex gap-4 p-6 w-full rounded-[calc(0.75rem-1px)]', {
|
165 |
-
'bg-bolt-elements-messages-background': isUserMessage || !isStreaming || (isStreaming && !isLast),
|
166 |
-
'bg-gradient-to-b from-bolt-elements-messages-background from-30% to-transparent':
|
167 |
-
isStreaming && isLast,
|
168 |
-
'mt-4': !isFirst,
|
169 |
-
})}
|
170 |
-
>
|
171 |
-
{isUserMessage && (
|
172 |
-
<div className="flex items-center justify-center w-[40px] h-[40px] overflow-hidden bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-500 rounded-full shrink-0 self-start">
|
173 |
-
{profile?.avatar ? (
|
174 |
-
<img
|
175 |
-
src={profile.avatar}
|
176 |
-
alt={profile?.username || 'User'}
|
177 |
-
className="w-full h-full object-cover"
|
178 |
-
loading="eager"
|
179 |
-
decoding="sync"
|
180 |
-
/>
|
181 |
) : (
|
182 |
-
<
|
183 |
)}
|
184 |
</div>
|
185 |
-
|
186 |
-
|
187 |
-
|
188 |
-
|
189 |
-
|
190 |
-
|
191 |
-
|
192 |
-
|
193 |
-
|
194 |
-
|
195 |
-
|
196 |
-
|
|
|
|
|
|
|
|
|
197 |
<button
|
198 |
-
onClick={() =>
|
199 |
-
key="i-ph:
|
200 |
className={classNames(
|
201 |
-
'i-ph:
|
202 |
'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors',
|
203 |
)}
|
204 |
/>
|
205 |
</WithTooltip>
|
206 |
-
|
207 |
-
|
208 |
-
|
209 |
-
|
210 |
-
|
211 |
-
|
212 |
-
|
213 |
-
|
214 |
-
|
215 |
-
|
216 |
-
|
217 |
-
|
218 |
-
|
219 |
-
)}
|
220 |
-
</div>
|
221 |
-
);
|
222 |
-
})
|
223 |
-
: null}
|
224 |
-
<div ref={messagesEndRef} /> {/* Add an empty div as scroll anchor */}
|
225 |
-
{isStreaming && (
|
226 |
-
<div className="text-center w-full text-bolt-elements-textSecondary i-svg-spinners:3-dots-fade text-4xl mt-4"></div>
|
227 |
-
)}
|
228 |
-
</div>
|
229 |
-
);
|
230 |
-
});
|
|
|
1 |
import type { Message } from 'ai';
|
2 |
+
import { Fragment } from 'react';
|
3 |
import { classNames } from '~/utils/classNames';
|
4 |
import { AssistantMessage } from './AssistantMessage';
|
5 |
import { UserMessage } from './UserMessage';
|
|
|
10 |
import WithTooltip from '~/components/ui/Tooltip';
|
11 |
import { useStore } from '@nanostores/react';
|
12 |
import { profileStore } from '~/lib/stores/profile';
|
13 |
+
import { forwardRef } from 'react';
|
14 |
+
import type { ForwardedRef } from 'react';
|
15 |
|
16 |
interface MessagesProps {
|
17 |
id?: string;
|
|
|
20 |
messages?: Message[];
|
21 |
}
|
22 |
|
23 |
+
export const Messages = forwardRef<HTMLDivElement, MessagesProps>(
|
24 |
+
(props: MessagesProps, ref: ForwardedRef<HTMLDivElement> | undefined) => {
|
25 |
+
const { id, isStreaming = false, messages = [] } = props;
|
26 |
+
const location = useLocation();
|
27 |
+
const profile = useStore(profileStore);
|
|
|
|
|
|
|
|
|
28 |
|
29 |
+
const handleRewind = (messageId: string) => {
|
30 |
+
const searchParams = new URLSearchParams(location.search);
|
31 |
+
searchParams.set('rewindTo', messageId);
|
32 |
+
window.location.search = searchParams.toString();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
33 |
};
|
34 |
|
35 |
+
const handleFork = async (messageId: string) => {
|
36 |
+
try {
|
37 |
+
if (!db || !chatId.get()) {
|
38 |
+
toast.error('Chat persistence is not available');
|
39 |
+
return;
|
40 |
+
}
|
41 |
|
42 |
+
const urlId = await forkChat(db, chatId.get()!, messageId);
|
43 |
+
window.location.href = `/chat/${urlId}`;
|
44 |
+
} catch (error) {
|
45 |
+
toast.error('Failed to fork chat: ' + (error as Error).message);
|
46 |
}
|
|
|
|
|
47 |
};
|
48 |
|
49 |
+
return (
|
50 |
+
<div id={id} className={props.className} ref={ref}>
|
51 |
+
{messages.length > 0
|
52 |
+
? messages.map((message, index) => {
|
53 |
+
const { role, content, id: messageId, annotations } = message;
|
54 |
+
const isUserMessage = role === 'user';
|
55 |
+
const isFirst = index === 0;
|
56 |
+
const isLast = index === messages.length - 1;
|
57 |
+
const isHidden = annotations?.includes('hidden');
|
58 |
+
|
59 |
+
if (isHidden) {
|
60 |
+
return <Fragment key={index} />;
|
61 |
+
}
|
62 |
+
|
63 |
+
return (
|
64 |
+
<div
|
65 |
+
key={index}
|
66 |
+
className={classNames('flex gap-4 p-6 w-full rounded-[calc(0.75rem-1px)]', {
|
67 |
+
'bg-bolt-elements-messages-background': isUserMessage || !isStreaming || (isStreaming && !isLast),
|
68 |
+
'bg-gradient-to-b from-bolt-elements-messages-background from-30% to-transparent':
|
69 |
+
isStreaming && isLast,
|
70 |
+
'mt-4': !isFirst,
|
71 |
+
})}
|
72 |
+
>
|
73 |
+
{isUserMessage && (
|
74 |
+
<div className="flex items-center justify-center w-[40px] h-[40px] overflow-hidden bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-500 rounded-full shrink-0 self-start">
|
75 |
+
{profile?.avatar ? (
|
76 |
+
<img
|
77 |
+
src={profile.avatar}
|
78 |
+
alt={profile?.username || 'User'}
|
79 |
+
className="w-full h-full object-cover"
|
80 |
+
loading="eager"
|
81 |
+
decoding="sync"
|
82 |
+
/>
|
83 |
+
) : (
|
84 |
+
<div className="i-ph:user-fill text-2xl" />
|
85 |
+
)}
|
86 |
+
</div>
|
87 |
+
)}
|
88 |
+
<div className="grid grid-col-1 w-full">
|
89 |
+
{isUserMessage ? (
|
90 |
+
<UserMessage content={content} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
91 |
) : (
|
92 |
+
<AssistantMessage content={content} annotations={message.annotations} />
|
93 |
)}
|
94 |
</div>
|
95 |
+
{!isUserMessage && (
|
96 |
+
<div className="flex gap-2 flex-col lg:flex-row">
|
97 |
+
{messageId && (
|
98 |
+
<WithTooltip tooltip="Revert to this message">
|
99 |
+
<button
|
100 |
+
onClick={() => handleRewind(messageId)}
|
101 |
+
key="i-ph:arrow-u-up-left"
|
102 |
+
className={classNames(
|
103 |
+
'i-ph:arrow-u-up-left',
|
104 |
+
'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors',
|
105 |
+
)}
|
106 |
+
/>
|
107 |
+
</WithTooltip>
|
108 |
+
)}
|
109 |
+
|
110 |
+
<WithTooltip tooltip="Fork chat from this message">
|
111 |
<button
|
112 |
+
onClick={() => handleFork(messageId)}
|
113 |
+
key="i-ph:git-fork"
|
114 |
className={classNames(
|
115 |
+
'i-ph:git-fork',
|
116 |
'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors',
|
117 |
)}
|
118 |
/>
|
119 |
</WithTooltip>
|
120 |
+
</div>
|
121 |
+
)}
|
122 |
+
</div>
|
123 |
+
);
|
124 |
+
})
|
125 |
+
: null}
|
126 |
+
{isStreaming && (
|
127 |
+
<div className="text-center w-full text-bolt-elements-textSecondary i-svg-spinners:3-dots-fade text-4xl mt-4"></div>
|
128 |
+
)}
|
129 |
+
</div>
|
130 |
+
);
|
131 |
+
},
|
132 |
+
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/lib/hooks/useSnapScroll.ts
CHANGED
@@ -1,52 +1,155 @@
|
|
1 |
import { useRef, useCallback } from 'react';
|
2 |
|
3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
4 |
const autoScrollRef = useRef(true);
|
5 |
const scrollNodeRef = useRef<HTMLDivElement>();
|
6 |
const onScrollRef = useRef<() => void>();
|
7 |
const observerRef = useRef<ResizeObserver>();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
8 |
|
9 |
-
|
10 |
-
|
11 |
-
const observer = new ResizeObserver(() => {
|
12 |
-
if (autoScrollRef.current && scrollNodeRef.current) {
|
13 |
-
const { scrollHeight, clientHeight } = scrollNodeRef.current;
|
14 |
-
const scrollTarget = scrollHeight - clientHeight;
|
15 |
|
16 |
-
|
17 |
-
|
18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
19 |
}
|
20 |
-
});
|
21 |
-
|
22 |
-
observer.observe(node);
|
23 |
-
} else {
|
24 |
-
observerRef.current?.disconnect();
|
25 |
-
observerRef.current = undefined;
|
26 |
-
}
|
27 |
-
}, []);
|
28 |
-
|
29 |
-
const scrollRef = useCallback((node: HTMLDivElement | null) => {
|
30 |
-
if (node) {
|
31 |
-
onScrollRef.current = () => {
|
32 |
-
const { scrollTop, scrollHeight, clientHeight } = node;
|
33 |
-
const scrollTarget = scrollHeight - clientHeight;
|
34 |
-
|
35 |
-
autoScrollRef.current = Math.abs(scrollTop - scrollTarget) <= 10;
|
36 |
};
|
37 |
|
38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
39 |
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
44 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
45 |
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
50 |
|
51 |
-
return [messageRef, scrollRef];
|
52 |
}
|
|
|
1 |
import { useRef, useCallback } from 'react';
|
2 |
|
3 |
+
interface ScrollOptions {
|
4 |
+
duration?: number;
|
5 |
+
easing?: 'ease' | 'ease-in' | 'ease-out' | 'ease-in-out' | 'cubic-bezier';
|
6 |
+
cubicBezier?: [number, number, number, number];
|
7 |
+
bottomThreshold?: number;
|
8 |
+
}
|
9 |
+
|
10 |
+
export function useSnapScroll(options: ScrollOptions = {}) {
|
11 |
+
const {
|
12 |
+
duration = 800,
|
13 |
+
easing = 'ease-in-out',
|
14 |
+
cubicBezier = [0.42, 0, 0.58, 1],
|
15 |
+
bottomThreshold = 50, // pixels from bottom to consider "scrolled to bottom"
|
16 |
+
} = options;
|
17 |
+
|
18 |
const autoScrollRef = useRef(true);
|
19 |
const scrollNodeRef = useRef<HTMLDivElement>();
|
20 |
const onScrollRef = useRef<() => void>();
|
21 |
const observerRef = useRef<ResizeObserver>();
|
22 |
+
const animationFrameRef = useRef<number>();
|
23 |
+
const lastScrollTopRef = useRef<number>(0);
|
24 |
+
|
25 |
+
const smoothScroll = useCallback(
|
26 |
+
(element: HTMLDivElement, targetPosition: number, duration: number, easingFunction: string) => {
|
27 |
+
const startPosition = element.scrollTop;
|
28 |
+
const distance = targetPosition - startPosition;
|
29 |
+
const startTime = performance.now();
|
30 |
+
|
31 |
+
const bezierPoints = easingFunction === 'cubic-bezier' ? cubicBezier : [0.42, 0, 0.58, 1];
|
32 |
+
|
33 |
+
const cubicBezierFunction = (t: number): number => {
|
34 |
+
const [, y1, , y2] = bezierPoints;
|
35 |
+
|
36 |
+
/*
|
37 |
+
* const cx = 3 * x1;
|
38 |
+
* const bx = 3 * (x2 - x1) - cx;
|
39 |
+
* const ax = 1 - cx - bx;
|
40 |
+
*/
|
41 |
+
|
42 |
+
const cy = 3 * y1;
|
43 |
+
const by = 3 * (y2 - y1) - cy;
|
44 |
+
const ay = 1 - cy - by;
|
45 |
|
46 |
+
// const sampleCurveX = (t: number) => ((ax * t + bx) * t + cx) * t;
|
47 |
+
const sampleCurveY = (t: number) => ((ay * t + by) * t + cy) * t;
|
|
|
|
|
|
|
|
|
48 |
|
49 |
+
return sampleCurveY(t);
|
50 |
+
};
|
51 |
+
|
52 |
+
const animation = (currentTime: number) => {
|
53 |
+
const elapsedTime = currentTime - startTime;
|
54 |
+
const progress = Math.min(elapsedTime / duration, 1);
|
55 |
+
|
56 |
+
const easedProgress = cubicBezierFunction(progress);
|
57 |
+
const newPosition = startPosition + distance * easedProgress;
|
58 |
+
|
59 |
+
// Only scroll if auto-scroll is still enabled
|
60 |
+
if (autoScrollRef.current) {
|
61 |
+
element.scrollTop = newPosition;
|
62 |
+
}
|
63 |
+
|
64 |
+
if (progress < 1 && autoScrollRef.current) {
|
65 |
+
animationFrameRef.current = requestAnimationFrame(animation);
|
66 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
67 |
};
|
68 |
|
69 |
+
if (animationFrameRef.current) {
|
70 |
+
cancelAnimationFrame(animationFrameRef.current);
|
71 |
+
}
|
72 |
+
|
73 |
+
animationFrameRef.current = requestAnimationFrame(animation);
|
74 |
+
},
|
75 |
+
[cubicBezier],
|
76 |
+
);
|
77 |
+
|
78 |
+
const isScrolledToBottom = useCallback(
|
79 |
+
(element: HTMLDivElement): boolean => {
|
80 |
+
const { scrollTop, scrollHeight, clientHeight } = element;
|
81 |
+
return scrollHeight - scrollTop - clientHeight <= bottomThreshold;
|
82 |
+
},
|
83 |
+
[bottomThreshold],
|
84 |
+
);
|
85 |
+
|
86 |
+
const messageRef = useCallback(
|
87 |
+
(node: HTMLDivElement | null) => {
|
88 |
+
if (node) {
|
89 |
+
const observer = new ResizeObserver(() => {
|
90 |
+
if (autoScrollRef.current && scrollNodeRef.current) {
|
91 |
+
const { scrollHeight, clientHeight } = scrollNodeRef.current;
|
92 |
+
const scrollTarget = scrollHeight - clientHeight;
|
93 |
+
|
94 |
+
smoothScroll(scrollNodeRef.current, scrollTarget, duration, easing);
|
95 |
+
}
|
96 |
+
});
|
97 |
|
98 |
+
observer.observe(node);
|
99 |
+
observerRef.current = observer;
|
100 |
+
} else {
|
101 |
+
observerRef.current?.disconnect();
|
102 |
+
observerRef.current = undefined;
|
103 |
+
|
104 |
+
if (animationFrameRef.current) {
|
105 |
+
cancelAnimationFrame(animationFrameRef.current);
|
106 |
+
animationFrameRef.current = undefined;
|
107 |
+
}
|
108 |
}
|
109 |
+
},
|
110 |
+
[duration, easing, smoothScroll],
|
111 |
+
);
|
112 |
+
|
113 |
+
const scrollRef = useCallback(
|
114 |
+
(node: HTMLDivElement | null) => {
|
115 |
+
if (node) {
|
116 |
+
onScrollRef.current = () => {
|
117 |
+
const { scrollTop } = node;
|
118 |
+
|
119 |
+
// Detect scroll direction
|
120 |
+
const isScrollingUp = scrollTop < lastScrollTopRef.current;
|
121 |
|
122 |
+
// Update auto-scroll based on scroll direction and position
|
123 |
+
if (isScrollingUp) {
|
124 |
+
// Disable auto-scroll when scrolling up
|
125 |
+
autoScrollRef.current = false;
|
126 |
+
} else if (isScrolledToBottom(node)) {
|
127 |
+
// Re-enable auto-scroll when manually scrolled to bottom
|
128 |
+
autoScrollRef.current = true;
|
129 |
+
}
|
130 |
+
|
131 |
+
// Store current scroll position for next comparison
|
132 |
+
lastScrollTopRef.current = scrollTop;
|
133 |
+
};
|
134 |
+
|
135 |
+
node.addEventListener('scroll', onScrollRef.current);
|
136 |
+
scrollNodeRef.current = node;
|
137 |
+
} else {
|
138 |
+
if (onScrollRef.current && scrollNodeRef.current) {
|
139 |
+
scrollNodeRef.current.removeEventListener('scroll', onScrollRef.current);
|
140 |
+
}
|
141 |
+
|
142 |
+
if (animationFrameRef.current) {
|
143 |
+
cancelAnimationFrame(animationFrameRef.current);
|
144 |
+
animationFrameRef.current = undefined;
|
145 |
+
}
|
146 |
+
|
147 |
+
scrollNodeRef.current = undefined;
|
148 |
+
onScrollRef.current = undefined;
|
149 |
+
}
|
150 |
+
},
|
151 |
+
[isScrolledToBottom],
|
152 |
+
);
|
153 |
|
154 |
+
return [messageRef, scrollRef] as const;
|
155 |
}
|