chore(message): auto scroll to bottom

This commit is contained in:
molvqingtai 2023-10-16 01:46:09 +08:00
parent 51b562b7bd
commit fff39d745a
7 changed files with 100 additions and 58 deletions

View file

@ -18,9 +18,15 @@ const EmojiButton: FC<EmojiButtonProps> = ({ onSelect }) => {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const handleSelect = (value: string) => { const handleSelect = (value: string) => {
onSelect?.(value)
setOpen(false) setOpen(false)
onSelect?.(value)
} }
const handleCloseAutoFocus = (event: Event) => {
// Close does not trigger focus
event.preventDefault()
}
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
@ -28,7 +34,7 @@ const EmojiButton: FC<EmojiButtonProps> = ({ onSelect }) => {
<SmileIcon size={20} /> <SmileIcon size={20} />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="z-top w-72 px-0"> <PopoverContent className="z-top w-72 px-0" onCloseAutoFocus={handleCloseAutoFocus}>
<ScrollArea className="h-72 w-72 px-3"> <ScrollArea className="h-72 w-72 px-3">
{emojiGroups.map((group, index) => { {emojiGroups.map((group, index) => {
return ( return (

View file

@ -1,5 +1,6 @@
import { type FC, type ChangeEvent, type KeyboardEvent } from 'react' import { type ChangeEvent, type KeyboardEvent } from 'react'
import React from 'react'
import { Textarea } from '@/components/ui/Textarea' import { Textarea } from '@/components/ui/Textarea'
import { Markdown } from '@/components/ui/Markdown' import { Markdown } from '@/components/ui/Markdown'
import { cn } from '@/utils' import { cn } from '@/utils'
@ -9,11 +10,13 @@ export interface MessageInputProps {
className?: string className?: string
maxLength?: number maxLength?: number
preview?: boolean preview?: boolean
autoFocus?: boolean
onInput?: (value: string) => void onInput?: (value: string) => void
onEnter?: (value: string) => void onEnter?: (value: string) => void
} }
const MessageInput: FC<MessageInputProps> = ({ value = '', className, maxLength = 500, onInput, onEnter, preview }) => { const MessageInput = React.forwardRef<HTMLTextAreaElement, MessageInputProps>(
({ value = '', className, maxLength = 500, onInput, onEnter, preview, autoFocus }, ref) => {
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => { const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !(e.shiftKey || e.ctrlKey || e.altKey || e.metaKey)) { if (e.key === 'Enter' && !(e.shiftKey || e.ctrlKey || e.altKey || e.metaKey)) {
e.preventDefault() e.preventDefault()
@ -35,7 +38,9 @@ const MessageInput: FC<MessageInputProps> = ({ value = '', className, maxLength
className="grid after:pointer-events-none after:invisible after:col-start-1 after:col-end-2 after:row-start-1 after:row-end-2 after:box-border after:max-h-28 after:w-full after:overflow-x-hidden after:whitespace-pre-wrap after:break-words after:rounded-lg after:border after:px-3 after:py-2 after:pb-5 after:text-sm after:content-[attr(data-value)] after:2xl:max-h-40" className="grid after:pointer-events-none after:invisible after:col-start-1 after:col-end-2 after:row-start-1 after:row-end-2 after:box-border after:max-h-28 after:w-full after:overflow-x-hidden after:whitespace-pre-wrap after:break-words after:rounded-lg after:border after:px-3 after:py-2 after:pb-5 after:text-sm after:content-[attr(data-value)] after:2xl:max-h-40"
> >
<Textarea <Textarea
ref={ref}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
autoFocus={autoFocus}
maxLength={maxLength} maxLength={maxLength}
className="col-start-1 col-end-2 row-start-1 row-end-2 box-border max-h-28 resize-none overflow-x-hidden whitespace-pre-wrap break-words rounded-lg bg-gray-50 pb-5 text-sm 2xl:max-h-40" className="col-start-1 col-end-2 row-start-1 row-end-2 box-border max-h-28 resize-none overflow-x-hidden whitespace-pre-wrap break-words rounded-lg bg-gray-50 pb-5 text-sm 2xl:max-h-40"
rows={2} rows={2}
@ -51,6 +56,7 @@ const MessageInput: FC<MessageInputProps> = ({ value = '', className, maxLength
</div> </div>
) )
} }
)
MessageInput.displayName = 'MessageInput' MessageInput.displayName = 'MessageInput'

View file

@ -9,9 +9,10 @@ import { Markdown } from '@/components/ui/Markdown'
export interface MessageItemProps { export interface MessageItemProps {
data: Message data: Message
index?: number
} }
const MessageItem: FC<MessageItemProps> = ({ data }) => { const MessageItem: FC<MessageItemProps> = ({ data, index }) => {
const [formatData, setFormatData] = useState({ const [formatData, setFormatData] = useState({
...data, ...data,
date: format(data.date, 'yyyy/MM/dd HH:mm:ss') date: format(data.date, 'yyyy/MM/dd HH:mm:ss')
@ -28,7 +29,10 @@ const MessageItem: FC<MessageItemProps> = ({ data }) => {
} }
return ( return (
<div className="box-border grid grid-cols-[auto_1fr] gap-x-2 px-4 first:pt-4 last:pb-4"> <div
data-index={index}
className="box-border grid grid-cols-[auto_1fr] gap-x-2 px-4 [content-visibility:auto] first:pt-4 last:pb-4"
>
<Avatar> <Avatar>
<AvatarImage src={formatData.avatar} /> <AvatarImage src={formatData.avatar} />
<AvatarFallback>{formatData.username}</AvatarFallback> <AvatarFallback>{formatData.username}</AvatarFallback>

View file

@ -1,5 +1,6 @@
import { type ReactElement, type FC } from 'react' import { type ReactElement } from 'react'
import React from 'react'
import { type MessageItemProps } from './MessageItem' import { type MessageItemProps } from './MessageItem'
import { ScrollArea } from '@/components/ui/ScrollArea' import { ScrollArea } from '@/components/ui/ScrollArea'
@ -7,9 +8,13 @@ export interface MessageListProps {
children?: Array<ReactElement<MessageItemProps>> children?: Array<ReactElement<MessageItemProps>>
} }
// [&>div>div]:!block fix word-break: break-word; // [&>div>div]:!block fix word-break: break-word;
const MessageList: FC<MessageListProps> = ({ children }) => { const MessageList = React.forwardRef<HTMLDivElement, MessageListProps>(({ children }, ref) => {
return <ScrollArea className="[&>div>div]:!block">{children}</ScrollArea> return (
} <ScrollArea ref={ref} className="[&>div>div]:!block">
{children}
</ScrollArea>
)
})
MessageList.displayName = 'MessageList' MessageList.displayName = 'MessageList'

View file

@ -6,7 +6,7 @@ export interface AppContainerProps {
const AppContainer: FC<AppContainerProps> = ({ children }) => { const AppContainer: FC<AppContainerProps> = ({ children }) => {
return ( return (
<div className="fixed inset-y-10 right-10 z-top box-border grid w-1/4 grid-flow-col grid-rows-[auto_1fr_auto] overflow-hidden rounded-xl bg-slate-50 font-sans shadow-2xl transition-transform"> <div className="fixed inset-y-10 right-10 z-top box-border grid w-1/4 min-w-[375px] grid-flow-col grid-rows-[auto_1fr_auto] overflow-hidden rounded-xl bg-slate-50 font-sans shadow-2xl transition-transform">
{children} {children}
</div> </div>
) )

View file

@ -1,5 +1,5 @@
import { type FC } from 'react' import { useRef, type FC } from 'react'
import { CornerDownLeftIcon, ImageIcon } from 'lucide-react' import { CornerDownLeftIcon } from 'lucide-react'
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react' import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import MessageInput from '@/components/MessageInput' import MessageInput from '@/components/MessageInput'
@ -12,7 +12,9 @@ const Footer: FC = () => {
const send = useRemeshSend() const send = useRemeshSend()
const messageListDomain = useRemeshDomain(MessageListDomain()) const messageListDomain = useRemeshDomain(MessageListDomain())
const messageInputDomain = useRemeshDomain(MessageInputDomain()) const messageInputDomain = useRemeshDomain(MessageInputDomain())
const messageText = useRemeshQuery(messageInputDomain.query.MessageQuery()) const messageBody = useRemeshQuery(messageInputDomain.query.MessageQuery())
const inputRef = useRef<HTMLTextAreaElement>(null)
const handleInput = (value: string) => { const handleInput = (value: string) => {
send(messageInputDomain.command.InputCommand(value)) send(messageInputDomain.command.InputCommand(value))
@ -21,7 +23,7 @@ const Footer: FC = () => {
const message = { const message = {
username: '墨绿青苔', username: '墨绿青苔',
avatar: 'https://avatars.githubusercontent.com/u/10251037?v=4', avatar: 'https://avatars.githubusercontent.com/u/10251037?v=4',
body: messageText.trim(), body: messageBody.trim(),
date: Date.now(), date: Date.now(),
likeChecked: false, likeChecked: false,
likeCount: 0, likeCount: 0,
@ -35,23 +37,25 @@ const Footer: FC = () => {
} }
const handleEmojiSelect = (emoji: string) => { const handleEmojiSelect = (emoji: string) => {
send(messageInputDomain.command.InputCommand(`${messageText}${emoji}`)) send(messageInputDomain.command.InputCommand(`${messageBody}${emoji}`))
inputRef.current?.focus()
} }
return ( return (
<div className="relative z-10 grid gap-y-2 px-4 pb-4 pt-2 before:absolute before:-top-4 before:left-0 before:h-4 before:w-full before:bg-gradient-to-t before:from-slate-50 before:from-30% before:to-transparent"> <div className="relative z-10 grid gap-y-2 px-4 pb-4 pt-2 before:absolute before:-top-4 before:left-0 before:h-4 before:w-full before:bg-gradient-to-t before:from-slate-50 before:from-30% before:to-transparent">
<MessageInput <MessageInput
value={messageText} ref={inputRef}
value={messageBody}
onEnter={handleSend} onEnter={handleSend}
onInput={handleInput} onInput={handleInput}
maxLength={MESSAGE_MAX_LENGTH} maxLength={MESSAGE_MAX_LENGTH}
></MessageInput> ></MessageInput>
<div className="grid grid-cols-[auto_auto_1fr] items-center justify-items-end"> <div className="flex items-center">
<EmojiButton onSelect={handleEmojiSelect}></EmojiButton> <EmojiButton onSelect={handleEmojiSelect}></EmojiButton>
<Button variant="ghost" size="icon"> {/* <Button variant="ghost" size="icon">
<ImageIcon size={20} /> <ImageIcon size={20} />
</Button> </Button> */}
<Button size="sm" onClick={handleSend}> <Button className="ml-auto" size="sm" onClick={handleSend}>
<span className="mr-2">Send</span> <span className="mr-2">Send</span>
<CornerDownLeftIcon className="text-slate-400" size={12}></CornerDownLeftIcon> <CornerDownLeftIcon className="text-slate-400" size={12}></CornerDownLeftIcon>
</Button> </Button>

View file

@ -1,5 +1,6 @@
import { type FC } from 'react' import { useEffect, type FC, useRef } from 'react'
import { useRemeshDomain, useRemeshQuery } from 'remesh-react' import { useRemeshDomain, useRemeshQuery } from 'remesh-react'
import MessageList from '@/components/MessageList' import MessageList from '@/components/MessageList'
import MessageItem from '@/components/MessageItem' import MessageItem from '@/components/MessageItem'
import MessageListDomain from '@/domain/MessageList' import MessageListDomain from '@/domain/MessageList'
@ -7,11 +8,27 @@ import MessageListDomain from '@/domain/MessageList'
const Main: FC = () => { const Main: FC = () => {
const messageListDomain = useRemeshDomain(MessageListDomain()) const messageListDomain = useRemeshDomain(MessageListDomain())
const messageList = useRemeshQuery(messageListDomain.query.ListQuery()) const messageList = useRemeshQuery(messageListDomain.query.ListQuery())
const messageListRef = useRef<HTMLDivElement>(null)
const isUpdate = useRef(false)
useEffect(() => {
const lastMessageRef = messageListRef.current?.querySelector('[data-index]:last-child')
const timerId = setTimeout(() => {
requestAnimationFrame(() => {
console.log(isUpdate.current)
lastMessageRef?.scrollIntoView({ behavior: isUpdate.current ? 'smooth' : 'instant', block: 'end' })
isUpdate.current = true
})
}, 0)
return () => clearTimeout(timerId)
}, [messageList.length])
return ( return (
<MessageList> <MessageList ref={messageListRef}>
{messageList.map((message) => ( {messageList.map((message, index) => (
<MessageItem key={message.id} data={message}></MessageItem> <MessageItem key={message.id} data={message} index={index}></MessageItem>
))} ))}
</MessageList> </MessageList>
) )