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 |
}
|