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 [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 (
|
||||||
|
|
|
@ -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,48 +10,53 @@ 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>(
|
||||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
({ value = '', className, maxLength = 500, onInput, onEnter, preview, autoFocus }, ref) => {
|
||||||
if (e.key === 'Enter' && !(e.shiftKey || e.ctrlKey || e.altKey || e.metaKey)) {
|
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
e.preventDefault()
|
if (e.key === 'Enter' && !(e.shiftKey || e.ctrlKey || e.altKey || e.metaKey)) {
|
||||||
onEnter?.(value)
|
e.preventDefault()
|
||||||
|
onEnter?.(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleInput = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
onInput?.(e.target.value)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
const handleInput = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
|
||||||
onInput?.(e.target.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('relative', className)}>
|
<div className={cn('relative', className)}>
|
||||||
{preview ? (
|
{preview ? (
|
||||||
<Markdown className="max-h-32 rounded-lg border border-input bg-gray-50 2xl:max-h-40">{value}</Markdown>
|
<Markdown className="max-h-32 rounded-lg border border-input bg-gray-50 2xl:max-h-40">{value}</Markdown>
|
||||||
) : (
|
) : (
|
||||||
// Hack: Auto-Growing Textarea
|
// Hack: Auto-Growing Textarea
|
||||||
<div
|
<div
|
||||||
data-value={value}
|
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"
|
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
|
||||||
onKeyDown={handleKeyDown}
|
ref={ref}
|
||||||
maxLength={maxLength}
|
onKeyDown={handleKeyDown}
|
||||||
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"
|
autoFocus={autoFocus}
|
||||||
rows={2}
|
maxLength={maxLength}
|
||||||
value={value}
|
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"
|
||||||
placeholder="Type your message here."
|
rows={2}
|
||||||
onInput={handleInput}
|
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>
|
||||||
)}
|
|
||||||
<div className="absolute bottom-1 right-3 rounded-lg text-xs text-slate-400">
|
|
||||||
{value?.length ?? 0}/{maxLength}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
)
|
}
|
||||||
}
|
)
|
||||||
|
|
||||||
MessageInput.displayName = 'MessageInput'
|
MessageInput.displayName = 'MessageInput'
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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'
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
Loading…
Reference in a new issue