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 handleSelect = (value: string) => {
onSelect?.(value)
setOpen(false)
onSelect?.(value)
}
const handleCloseAutoFocus = (event: Event) => {
// Close does not trigger focus
event.preventDefault()
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
@ -28,7 +34,7 @@ const EmojiButton: FC<EmojiButtonProps> = ({ onSelect }) => {
<SmileIcon size={20} />
</Button>
</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">
{emojiGroups.map((group, index) => {
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 { Markdown } from '@/components/ui/Markdown'
import { cn } from '@/utils'
@ -9,48 +10,53 @@ export interface MessageInputProps {
className?: string
maxLength?: number
preview?: boolean
autoFocus?: boolean
onInput?: (value: string) => void
onEnter?: (value: string) => void
}
const MessageInput: FC<MessageInputProps> = ({ value = '', className, maxLength = 500, onInput, onEnter, preview }) => {
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !(e.shiftKey || e.ctrlKey || e.altKey || e.metaKey)) {
e.preventDefault()
onEnter?.(value)
const MessageInput = React.forwardRef<HTMLTextAreaElement, MessageInputProps>(
({ value = '', className, maxLength = 500, onInput, onEnter, preview, autoFocus }, ref) => {
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !(e.shiftKey || e.ctrlKey || e.altKey || e.metaKey)) {
e.preventDefault()
onEnter?.(value)
}
}
const handleInput = (e: ChangeEvent<HTMLTextAreaElement>) => {
onInput?.(e.target.value)
}
}
const handleInput = (e: ChangeEvent<HTMLTextAreaElement>) => {
onInput?.(e.target.value)
}
return (
<div className={cn('relative', className)}>
{preview ? (
<Markdown className="max-h-32 rounded-lg border border-input bg-gray-50 2xl:max-h-40">{value}</Markdown>
) : (
// Hack: Auto-Growing Textarea
<div
data-value={value}
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
onKeyDown={handleKeyDown}
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"
rows={2}
value={value}
placeholder="Type your message here."
onInput={handleInput}
/>
return (
<div className={cn('relative', className)}>
{preview ? (
<Markdown className="max-h-32 rounded-lg border border-input bg-gray-50 2xl:max-h-40">{value}</Markdown>
) : (
// Hack: Auto-Growing Textarea
<div
data-value={value}
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
ref={ref}
onKeyDown={handleKeyDown}
autoFocus={autoFocus}
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"
rows={2}
value={value}
placeholder="Type your message here."
onInput={handleInput}
/>
</div>
)}
<div className="absolute bottom-1 right-3 rounded-lg text-xs text-slate-400">
{value?.length ?? 0}/{maxLength}
</div>
)}
<div className="absolute bottom-1 right-3 rounded-lg text-xs text-slate-400">
{value?.length ?? 0}/{maxLength}
</div>
</div>
)
}
)
}
)
MessageInput.displayName = 'MessageInput'

View file

@ -9,9 +9,10 @@ import { Markdown } from '@/components/ui/Markdown'
export interface MessageItemProps {
data: Message
index?: number
}
const MessageItem: FC<MessageItemProps> = ({ data }) => {
const MessageItem: FC<MessageItemProps> = ({ data, index }) => {
const [formatData, setFormatData] = useState({
...data,
date: format(data.date, 'yyyy/MM/dd HH:mm:ss')
@ -28,7 +29,10 @@ const MessageItem: FC<MessageItemProps> = ({ data }) => {
}
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>
<AvatarImage src={formatData.avatar} />
<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 { ScrollArea } from '@/components/ui/ScrollArea'
@ -7,9 +8,13 @@ export interface MessageListProps {
children?: Array<ReactElement<MessageItemProps>>
}
// [&>div>div]:!block fix word-break: break-word;
const MessageList: FC<MessageListProps> = ({ children }) => {
return <ScrollArea className="[&>div>div]:!block">{children}</ScrollArea>
}
const MessageList = React.forwardRef<HTMLDivElement, MessageListProps>(({ children }, ref) => {
return (
<ScrollArea ref={ref} className="[&>div>div]:!block">
{children}
</ScrollArea>
)
})
MessageList.displayName = 'MessageList'

View file

@ -6,7 +6,7 @@ export interface AppContainerProps {
const AppContainer: FC<AppContainerProps> = ({ children }) => {
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}
</div>
)

View file

@ -1,5 +1,5 @@
import { type FC } from 'react'
import { CornerDownLeftIcon, ImageIcon } from 'lucide-react'
import { useRef, type FC } from 'react'
import { CornerDownLeftIcon } from 'lucide-react'
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
import { Button } from '@/components/ui/Button'
import MessageInput from '@/components/MessageInput'
@ -12,7 +12,9 @@ const Footer: FC = () => {
const send = useRemeshSend()
const messageListDomain = useRemeshDomain(MessageListDomain())
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) => {
send(messageInputDomain.command.InputCommand(value))
@ -21,7 +23,7 @@ const Footer: FC = () => {
const message = {
username: '墨绿青苔',
avatar: 'https://avatars.githubusercontent.com/u/10251037?v=4',
body: messageText.trim(),
body: messageBody.trim(),
date: Date.now(),
likeChecked: false,
likeCount: 0,
@ -35,23 +37,25 @@ const Footer: FC = () => {
}
const handleEmojiSelect = (emoji: string) => {
send(messageInputDomain.command.InputCommand(`${messageText}${emoji}`))
send(messageInputDomain.command.InputCommand(`${messageBody}${emoji}`))
inputRef.current?.focus()
}
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">
<MessageInput
value={messageText}
ref={inputRef}
value={messageBody}
onEnter={handleSend}
onInput={handleInput}
maxLength={MESSAGE_MAX_LENGTH}
></MessageInput>
<div className="grid grid-cols-[auto_auto_1fr] items-center justify-items-end">
<div className="flex items-center">
<EmojiButton onSelect={handleEmojiSelect}></EmojiButton>
<Button variant="ghost" size="icon">
{/* <Button variant="ghost" size="icon">
<ImageIcon size={20} />
</Button>
<Button size="sm" onClick={handleSend}>
</Button> */}
<Button className="ml-auto" size="sm" onClick={handleSend}>
<span className="mr-2">Send</span>
<CornerDownLeftIcon className="text-slate-400" size={12}></CornerDownLeftIcon>
</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 MessageList from '@/components/MessageList'
import MessageItem from '@/components/MessageItem'
import MessageListDomain from '@/domain/MessageList'
@ -7,11 +8,27 @@ import MessageListDomain from '@/domain/MessageList'
const Main: FC = () => {
const messageListDomain = useRemeshDomain(MessageListDomain())
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 (
<MessageList>
{messageList.map((message) => (
<MessageItem key={message.id} data={message}></MessageItem>
<MessageList ref={messageListRef}>
{messageList.map((message, index) => (
<MessageItem key={message.id} data={message} index={index}></MessageItem>
))}
</MessageList>
)