Improve scroll anchor (#275)
This commit is contained in:
parent
b6cab643ef
commit
43c7cbb21e
12 changed files with 169 additions and 101 deletions
|
|
@ -8,9 +8,7 @@ export default async function ChatLayout({ children }: ChatLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-[calc(100vh_-_theme(spacing.16))] overflow-hidden">
|
<div className="relative flex h-[calc(100vh_-_theme(spacing.16))] overflow-hidden">
|
||||||
<SidebarDesktop />
|
<SidebarDesktop />
|
||||||
<div className="group w-full overflow-auto pl-0 animate-in duration-300 ease-in-out peer-[[data-state=open]]:lg:pl-[250px] peer-[[data-state=open]]:xl:pl-[300px]">
|
{children}
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,20 @@
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useAtBottom } from '@/lib/hooks/use-at-bottom'
|
|
||||||
import { Button, type ButtonProps } from '@/components/ui/button'
|
import { Button, type ButtonProps } from '@/components/ui/button'
|
||||||
import { IconArrowDown } from '@/components/ui/icons'
|
import { IconArrowDown } from '@/components/ui/icons'
|
||||||
|
|
||||||
export function ButtonScrollToBottom({ className, ...props }: ButtonProps) {
|
interface ButtonScrollToBottomProps extends ButtonProps {
|
||||||
const isAtBottom = useAtBottom()
|
isAtBottom: boolean
|
||||||
|
scrollToBottom: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ButtonScrollToBottom({
|
||||||
|
className,
|
||||||
|
isAtBottom,
|
||||||
|
scrollToBottom,
|
||||||
|
...props
|
||||||
|
}: ButtonScrollToBottomProps) {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -19,12 +26,7 @@ export function ButtonScrollToBottom({ className, ...props }: ButtonProps) {
|
||||||
isAtBottom ? 'opacity-0' : 'opacity-100',
|
isAtBottom ? 'opacity-0' : 'opacity-100',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
onClick={() =>
|
onClick={() => scrollToBottom()}
|
||||||
window.scrollTo({
|
|
||||||
top: document.body.offsetHeight,
|
|
||||||
behavior: 'smooth'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<IconArrowDown />
|
<IconArrowDown />
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Separator } from '@/components/ui/separator'
|
||||||
import { UIState } from '@/lib/chat/actions'
|
import { UIState } from '@/lib/chat/actions'
|
||||||
import { Session } from '@/lib/types'
|
import { Session } from '@/lib/types'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { ExclamationTriangleIcon } from '@radix-ui/react-icons'
|
||||||
|
|
||||||
export interface ChatList {
|
export interface ChatList {
|
||||||
messages: UIState
|
messages: UIState
|
||||||
|
|
@ -17,19 +18,27 @@ export function ChatList({ messages, session, isShared }: ChatList) {
|
||||||
return (
|
return (
|
||||||
<div className="relative mx-auto max-w-2xl px-4">
|
<div className="relative mx-auto max-w-2xl px-4">
|
||||||
{!isShared && !session ? (
|
{!isShared && !session ? (
|
||||||
<div className="mb-8 rounded-lg border bg-white p-4 dark:bg-zinc-950">
|
<>
|
||||||
<p className="text-muted-foreground leading-normal">
|
<div className="group relative mb-4 flex items-start md:-ml-12">
|
||||||
Please{' '}
|
<div className="bg-background flex size-[25px] shrink-0 select-none items-center justify-center rounded-md border shadow-sm">
|
||||||
<Link href="/login" className="underline">
|
<ExclamationTriangleIcon />
|
||||||
log in
|
</div>
|
||||||
</Link>{' '}
|
<div className="ml-4 flex-1 space-y-2 overflow-hidden px-1">
|
||||||
or{' '}
|
<p className="text-muted-foreground leading-normal">
|
||||||
<Link href="/signup" className="underline">
|
Please{' '}
|
||||||
sign up
|
<Link href="/login" className="underline">
|
||||||
</Link>{' '}
|
log in
|
||||||
to save and revisit your chat history!
|
</Link>{' '}
|
||||||
</p>
|
or{' '}
|
||||||
</div>
|
<Link href="/signup" className="underline">
|
||||||
|
sign up
|
||||||
|
</Link>{' '}
|
||||||
|
to save and revisit your chat history!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator className="my-4" />
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{messages.map((message, index) => (
|
{messages.map((message, index) => (
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,18 @@ export interface ChatPanelProps {
|
||||||
title?: string
|
title?: string
|
||||||
input: string
|
input: string
|
||||||
setInput: (value: string) => void
|
setInput: (value: string) => void
|
||||||
|
isAtBottom: boolean
|
||||||
|
scrollToBottom: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatPanel({ id, title, input, setInput }: ChatPanelProps) {
|
export function ChatPanel({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
input,
|
||||||
|
setInput,
|
||||||
|
isAtBottom,
|
||||||
|
scrollToBottom
|
||||||
|
}: ChatPanelProps) {
|
||||||
const [aiState] = useAIState()
|
const [aiState] = useAIState()
|
||||||
const [messages, setMessages] = useUIState<typeof AI>()
|
const [messages, setMessages] = useUIState<typeof AI>()
|
||||||
const { submitUserMessage } = useActions()
|
const { submitUserMessage } = useActions()
|
||||||
|
|
@ -33,24 +42,27 @@ export function ChatPanel({ id, title, input, setInput }: ChatPanelProps) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
heading: 'What is the price of',
|
heading: 'What is the price of',
|
||||||
subheading: 'DOGE in the stock market?',
|
subheading: '$DOGE right now?',
|
||||||
message: 'What is the price of DOGE in the stock market?'
|
message: 'What is the price of $DOGE right now?'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
heading: 'I would like to buy',
|
heading: 'I would like to buy',
|
||||||
subheading: '42 DOGE coins',
|
subheading: '42 $DOGE',
|
||||||
message: `I would like to buy 42 DOGE coins`
|
message: `I would like to buy 42 $DOGE`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
heading: 'What are some',
|
heading: 'What are some',
|
||||||
subheading: `recent events about DOGE?`,
|
subheading: `recent events about $DOGE?`,
|
||||||
message: `What are some recent events about DOGE?`
|
message: `What are some recent events about $DOGE?`
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-x-0 bottom-0 w-full bg-gradient-to-b from-muted/30 from-0% to-muted/30 to-50% duration-300 ease-in-out animate-in dark:from-background/10 dark:from-10% dark:to-background/80 peer-[[data-state=open]]:group-[]:lg:pl-[250px] peer-[[data-state=open]]:group-[]:xl:pl-[300px]">
|
<div className="fixed inset-x-0 bottom-0 w-full bg-gradient-to-b from-muted/30 from-0% to-muted/30 to-50% duration-300 ease-in-out animate-in dark:from-background/10 dark:from-10% dark:to-background/80 peer-[[data-state=open]]:group-[]:lg:pl-[250px] peer-[[data-state=open]]:group-[]:xl:pl-[300px]">
|
||||||
<ButtonScrollToBottom />
|
<ButtonScrollToBottom
|
||||||
|
isAtBottom={isAtBottom}
|
||||||
|
scrollToBottom={scrollToBottom}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="mx-auto sm:max-w-2xl sm:px-4">
|
<div className="mx-auto sm:max-w-2xl sm:px-4">
|
||||||
<div className="mb-4 grid grid-cols-2 gap-2 px-4 sm:px-0">
|
<div className="mb-4 grid grid-cols-2 gap-2 px-4 sm:px-0">
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import * as React from 'react'
|
|
||||||
import { useInView } from 'react-intersection-observer'
|
|
||||||
import { useAtBottom } from '@/lib/hooks/use-at-bottom'
|
|
||||||
|
|
||||||
interface ChatScrollAnchorProps {
|
|
||||||
trackVisibility?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChatScrollAnchor({ trackVisibility }: ChatScrollAnchorProps) {
|
|
||||||
const isAtBottom = useAtBottom()
|
|
||||||
const { ref, entry, inView } = useInView({
|
|
||||||
trackVisibility,
|
|
||||||
delay: 100,
|
|
||||||
rootMargin: '0px 0px -125px 0px'
|
|
||||||
})
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (isAtBottom && trackVisibility && !inView) {
|
|
||||||
entry?.target.scrollIntoView({
|
|
||||||
block: 'start'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [inView, entry, isAtBottom, trackVisibility])
|
|
||||||
|
|
||||||
return <div ref={ref} className="h-px w-full" />
|
|
||||||
}
|
|
||||||
|
|
@ -4,13 +4,13 @@ import { cn } from '@/lib/utils'
|
||||||
import { ChatList } from '@/components/chat-list'
|
import { ChatList } from '@/components/chat-list'
|
||||||
import { ChatPanel } from '@/components/chat-panel'
|
import { ChatPanel } from '@/components/chat-panel'
|
||||||
import { EmptyScreen } from '@/components/empty-screen'
|
import { EmptyScreen } from '@/components/empty-screen'
|
||||||
import { ChatScrollAnchor } from '@/components/chat-scroll-anchor'
|
|
||||||
import { useLocalStorage } from '@/lib/hooks/use-local-storage'
|
import { useLocalStorage } from '@/lib/hooks/use-local-storage'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useUIState, useAIState } from 'ai/rsc'
|
import { useUIState, useAIState } from 'ai/rsc'
|
||||||
import { Session } from '@/lib/types'
|
import { Session } from '@/lib/types'
|
||||||
import { usePathname, useRouter } from 'next/navigation'
|
import { usePathname, useRouter } from 'next/navigation'
|
||||||
import { Message } from '@/lib/chat/actions'
|
import { Message } from '@/lib/chat/actions'
|
||||||
|
import { useScrollAnchor } from '@/lib/hooks/use-scroll-anchor'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
export interface ChatProps extends React.ComponentProps<'div'> {
|
export interface ChatProps extends React.ComponentProps<'div'> {
|
||||||
|
|
@ -26,7 +26,6 @@ export function Chat({ id, className, session, missingKeys }: ChatProps) {
|
||||||
const [input, setInput] = useState('')
|
const [input, setInput] = useState('')
|
||||||
const [messages] = useUIState()
|
const [messages] = useUIState()
|
||||||
const [aiState] = useAIState()
|
const [aiState] = useAIState()
|
||||||
const isLoading = true
|
|
||||||
|
|
||||||
const [_, setNewChatId] = useLocalStorage('newChatId', id)
|
const [_, setNewChatId] = useLocalStorage('newChatId', id)
|
||||||
|
|
||||||
|
|
@ -55,19 +54,32 @@ export function Chat({ id, className, session, missingKeys }: ChatProps) {
|
||||||
})
|
})
|
||||||
}, [missingKeys])
|
}, [missingKeys])
|
||||||
|
|
||||||
|
const { messagesRef, scrollRef, visibilityRef, isAtBottom, scrollToBottom } =
|
||||||
|
useScrollAnchor()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div
|
||||||
<div className={cn('pb-[200px] pt-4 md:pt-10', className)}>
|
className="group w-full overflow-auto pl-0 peer-[[data-state=open]]:lg:pl-[250px] peer-[[data-state=open]]:xl:pl-[300px]"
|
||||||
|
ref={scrollRef}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn('pb-[200px] pt-4 md:pt-10', className)}
|
||||||
|
ref={messagesRef}
|
||||||
|
>
|
||||||
{messages.length ? (
|
{messages.length ? (
|
||||||
<>
|
<ChatList messages={messages} isShared={false} session={session} />
|
||||||
<ChatList messages={messages} isShared={false} session={session} />
|
|
||||||
<ChatScrollAnchor trackVisibility={isLoading} />
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<EmptyScreen setInput={setInput} />
|
<EmptyScreen />
|
||||||
)}
|
)}
|
||||||
|
<div className="h-px w-full" ref={visibilityRef} />
|
||||||
</div>
|
</div>
|
||||||
<ChatPanel id={id} input={input} setInput={setInput} />
|
<ChatPanel
|
||||||
</>
|
id={id}
|
||||||
|
input={input}
|
||||||
|
setInput={setInput}
|
||||||
|
isAtBottom={isAtBottom}
|
||||||
|
scrollToBottom={scrollToBottom}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ const exampleMessages = [
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
export function EmptyScreen({ setInput }: Pick<UseChatHelpers, 'setInput'>) {
|
export function EmptyScreen() {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-2xl px-4">
|
<div className="mx-auto max-w-2xl px-4">
|
||||||
<div className="flex flex-col gap-2 rounded-lg border bg-background p-8">
|
<div className="flex flex-col gap-2 rounded-lg border bg-background p-8">
|
||||||
|
|
|
||||||
|
|
@ -60,12 +60,12 @@ export function SidebarItem({ index, chat, children }: SidebarItemProps) {
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="focus:bg-muted focus:ring-1 focus:ring-ring"
|
className="focus:bg-muted focus:ring-1 focus:ring-ring"
|
||||||
>
|
>
|
||||||
<IconUsers className="mr-2" />
|
<IconUsers className="mr-2 mt-1 text-zinc-500" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>This is a shared chat.</TooltipContent>
|
<TooltipContent>This is a shared chat.</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<IconMessage className="mr-2" />
|
<IconMessage className="mr-2 mt-1 text-zinc-500" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ export function UserMessage({ children }: { children: React.ReactNode }) {
|
||||||
<div className="flex size-[25px] shrink-0 select-none items-center justify-center rounded-md border bg-background shadow-sm">
|
<div className="flex size-[25px] shrink-0 select-none items-center justify-center rounded-md border bg-background shadow-sm">
|
||||||
<IconUser />
|
<IconUser />
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4 flex-1 space-y-2 overflow-hidden px-1">
|
<div className="ml-4 flex-1 space-y-2 overflow-hidden pl-2">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -103,7 +103,7 @@ export function BotCard({
|
||||||
>
|
>
|
||||||
<IconOpenAI />
|
<IconOpenAI />
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4 flex-1 px-1">{children}</div>
|
<div className="ml-4 flex-1 pl-2">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ export function Purchase({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="mt-6 w-full rounded-lg bg-green-500 px-4 py-2 font-bold text-zinc-900 hover:bg-green-600"
|
className="mt-6 w-full rounded-lg bg-green-400 px-4 py-2 font-bold text-zinc-900 hover:bg-green-500"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const response = await confirmPurchase(symbol, price, value)
|
const response = await confirmPurchase(symbol, price, value)
|
||||||
setPurchasingUI(response.purchasingUI)
|
setPurchasingUI(response.purchasingUI)
|
||||||
|
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
import * as React from 'react'
|
|
||||||
|
|
||||||
export function useAtBottom(offset = 0) {
|
|
||||||
const [isAtBottom, setIsAtBottom] = React.useState(false)
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const handleScroll = () => {
|
|
||||||
setIsAtBottom(
|
|
||||||
window.innerHeight + window.scrollY >=
|
|
||||||
document.body.offsetHeight - offset
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
|
||||||
handleScroll()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('scroll', handleScroll)
|
|
||||||
}
|
|
||||||
}, [offset])
|
|
||||||
|
|
||||||
return isAtBottom
|
|
||||||
}
|
|
||||||
86
lib/hooks/use-scroll-anchor.tsx
Normal file
86
lib/hooks/use-scroll-anchor.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
export const useScrollAnchor = () => {
|
||||||
|
const messagesRef = useRef<HTMLDivElement>(null)
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
const visibilityRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const [isAtBottom, setIsAtBottom] = useState(true)
|
||||||
|
const [isVisible, setIsVisible] = useState(false)
|
||||||
|
|
||||||
|
const scrollToBottom = useCallback(() => {
|
||||||
|
if (messagesRef.current) {
|
||||||
|
messagesRef.current.scrollIntoView({
|
||||||
|
block: 'end',
|
||||||
|
behavior: 'smooth'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (messagesRef.current) {
|
||||||
|
if (isAtBottom && !isVisible) {
|
||||||
|
messagesRef.current.scrollIntoView({
|
||||||
|
block: 'end'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isAtBottom, isVisible])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const { current } = scrollRef
|
||||||
|
|
||||||
|
if (current) {
|
||||||
|
const handleScroll = (event: Event) => {
|
||||||
|
const target = event.target as HTMLDivElement
|
||||||
|
const offset = 25
|
||||||
|
const isAtBottom =
|
||||||
|
target.scrollTop + target.clientHeight >= target.scrollHeight - offset
|
||||||
|
|
||||||
|
setIsAtBottom(isAtBottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
current.addEventListener('scroll', handleScroll, {
|
||||||
|
passive: true
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
current.removeEventListener('scroll', handleScroll)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visibilityRef.current) {
|
||||||
|
let observer = new IntersectionObserver(
|
||||||
|
entries => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setIsVisible(true)
|
||||||
|
} else {
|
||||||
|
setIsVisible(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rootMargin: '0px 0px -150px 0px'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
observer.observe(visibilityRef.current)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
messagesRef,
|
||||||
|
scrollRef,
|
||||||
|
visibilityRef,
|
||||||
|
scrollToBottom,
|
||||||
|
isAtBottom,
|
||||||
|
isVisible
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue