chore(message): auto scroll to bottom
This commit is contained in:
parent
51b562b7bd
commit
fff39d745a
7 changed files with 100 additions and 58 deletions
|
@ -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 (
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue