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 (
|
||||
<div className="relative flex h-[calc(100vh_-_theme(spacing.16))] overflow-hidden">
|
||||
<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}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,20 @@
|
|||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAtBottom } from '@/lib/hooks/use-at-bottom'
|
||||
import { Button, type ButtonProps } from '@/components/ui/button'
|
||||
import { IconArrowDown } from '@/components/ui/icons'
|
||||
|
||||
export function ButtonScrollToBottom({ className, ...props }: ButtonProps) {
|
||||
const isAtBottom = useAtBottom()
|
||||
interface ButtonScrollToBottomProps extends ButtonProps {
|
||||
isAtBottom: boolean
|
||||
scrollToBottom: () => void
|
||||
}
|
||||
|
||||
export function ButtonScrollToBottom({
|
||||
className,
|
||||
isAtBottom,
|
||||
scrollToBottom,
|
||||
...props
|
||||
}: ButtonScrollToBottomProps) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -19,12 +26,7 @@ export function ButtonScrollToBottom({ className, ...props }: ButtonProps) {
|
|||
isAtBottom ? 'opacity-0' : 'opacity-100',
|
||||
className
|
||||
)}
|
||||
onClick={() =>
|
||||
window.scrollTo({
|
||||
top: document.body.offsetHeight,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
onClick={() => scrollToBottom()}
|
||||
{...props}
|
||||
>
|
||||
<IconArrowDown />
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Separator } from '@/components/ui/separator'
|
|||
import { UIState } from '@/lib/chat/actions'
|
||||
import { Session } from '@/lib/types'
|
||||
import Link from 'next/link'
|
||||
import { ExclamationTriangleIcon } from '@radix-ui/react-icons'
|
||||
|
||||
export interface ChatList {
|
||||
messages: UIState
|
||||
|
|
@ -17,19 +18,27 @@ export function ChatList({ messages, session, isShared }: ChatList) {
|
|||
return (
|
||||
<div className="relative mx-auto max-w-2xl px-4">
|
||||
{!isShared && !session ? (
|
||||
<div className="mb-8 rounded-lg border bg-white p-4 dark:bg-zinc-950">
|
||||
<p className="text-muted-foreground leading-normal">
|
||||
Please{' '}
|
||||
<Link href="/login" className="underline">
|
||||
log in
|
||||
</Link>{' '}
|
||||
or{' '}
|
||||
<Link href="/signup" className="underline">
|
||||
sign up
|
||||
</Link>{' '}
|
||||
to save and revisit your chat history!
|
||||
</p>
|
||||
</div>
|
||||
<>
|
||||
<div className="group relative mb-4 flex items-start md:-ml-12">
|
||||
<div className="bg-background flex size-[25px] shrink-0 select-none items-center justify-center rounded-md border shadow-sm">
|
||||
<ExclamationTriangleIcon />
|
||||
</div>
|
||||
<div className="ml-4 flex-1 space-y-2 overflow-hidden px-1">
|
||||
<p className="text-muted-foreground leading-normal">
|
||||
Please{' '}
|
||||
<Link href="/login" className="underline">
|
||||
log in
|
||||
</Link>{' '}
|
||||
or{' '}
|
||||
<Link href="/signup" className="underline">
|
||||
sign up
|
||||
</Link>{' '}
|
||||
to save and revisit your chat history!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{messages.map((message, index) => (
|
||||
|
|
|
|||
|
|
@ -17,9 +17,18 @@ export interface ChatPanelProps {
|
|||
title?: string
|
||||
input: string
|
||||
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 [messages, setMessages] = useUIState<typeof AI>()
|
||||
const { submitUserMessage } = useActions()
|
||||
|
|
@ -33,24 +42,27 @@ export function ChatPanel({ id, title, input, setInput }: ChatPanelProps) {
|
|||
},
|
||||
{
|
||||
heading: 'What is the price of',
|
||||
subheading: 'DOGE in the stock market?',
|
||||
message: 'What is the price of DOGE in the stock market?'
|
||||
subheading: '$DOGE right now?',
|
||||
message: 'What is the price of $DOGE right now?'
|
||||
},
|
||||
{
|
||||
heading: 'I would like to buy',
|
||||
subheading: '42 DOGE coins',
|
||||
message: `I would like to buy 42 DOGE coins`
|
||||
subheading: '42 $DOGE',
|
||||
message: `I would like to buy 42 $DOGE`
|
||||
},
|
||||
{
|
||||
heading: 'What are some',
|
||||
subheading: `recent events about DOGE?`,
|
||||
message: `What are some recent events about DOGE?`
|
||||
subheading: `recent events about $DOGE?`,
|
||||
message: `What are some recent events about $DOGE?`
|
||||
}
|
||||
]
|
||||
|
||||
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]">
|
||||
<ButtonScrollToBottom />
|
||||
<ButtonScrollToBottom
|
||||
isAtBottom={isAtBottom}
|
||||
scrollToBottom={scrollToBottom}
|
||||
/>
|
||||
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -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 { ChatPanel } from '@/components/chat-panel'
|
||||
import { EmptyScreen } from '@/components/empty-screen'
|
||||
import { ChatScrollAnchor } from '@/components/chat-scroll-anchor'
|
||||
import { useLocalStorage } from '@/lib/hooks/use-local-storage'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useUIState, useAIState } from 'ai/rsc'
|
||||
import { Session } from '@/lib/types'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { Message } from '@/lib/chat/actions'
|
||||
import { useScrollAnchor } from '@/lib/hooks/use-scroll-anchor'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export interface ChatProps extends React.ComponentProps<'div'> {
|
||||
|
|
@ -26,7 +26,6 @@ export function Chat({ id, className, session, missingKeys }: ChatProps) {
|
|||
const [input, setInput] = useState('')
|
||||
const [messages] = useUIState()
|
||||
const [aiState] = useAIState()
|
||||
const isLoading = true
|
||||
|
||||
const [_, setNewChatId] = useLocalStorage('newChatId', id)
|
||||
|
||||
|
|
@ -55,19 +54,32 @@ export function Chat({ id, className, session, missingKeys }: ChatProps) {
|
|||
})
|
||||
}, [missingKeys])
|
||||
|
||||
const { messagesRef, scrollRef, visibilityRef, isAtBottom, scrollToBottom } =
|
||||
useScrollAnchor()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn('pb-[200px] pt-4 md:pt-10', className)}>
|
||||
<div
|
||||
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 ? (
|
||||
<>
|
||||
<ChatList messages={messages} isShared={false} session={session} />
|
||||
<ChatScrollAnchor trackVisibility={isLoading} />
|
||||
</>
|
||||
<ChatList messages={messages} isShared={false} session={session} />
|
||||
) : (
|
||||
<EmptyScreen setInput={setInput} />
|
||||
<EmptyScreen />
|
||||
)}
|
||||
<div className="h-px w-full" ref={visibilityRef} />
|
||||
</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 (
|
||||
<div className="mx-auto max-w-2xl px-4">
|
||||
<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}
|
||||
className="focus:bg-muted focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<IconUsers className="mr-2" />
|
||||
<IconUsers className="mr-2 mt-1 text-zinc-500" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>This is a shared chat.</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<IconMessage className="mr-2" />
|
||||
<IconMessage className="mr-2 mt-1 text-zinc-500" />
|
||||
)}
|
||||
</div>
|
||||
<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">
|
||||
<IconUser />
|
||||
</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}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -103,7 +103,7 @@ export function BotCard({
|
|||
>
|
||||
<IconOpenAI />
|
||||
</div>
|
||||
<div className="ml-4 flex-1 px-1">{children}</div>
|
||||
<div className="ml-4 flex-1 pl-2">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ export function Purchase({
|
|||
</div>
|
||||
|
||||
<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 () => {
|
||||
const response = await confirmPurchase(symbol, price, value)
|
||||
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