feat: support send image button

This commit is contained in:
molvqingtai 2024-10-29 10:22:21 +08:00
parent ca97c5e976
commit a01a93f260
9 changed files with 148 additions and 21 deletions

View file

@ -0,0 +1,34 @@
import { Button } from '@/components/ui/Button'
import { createElement } from '@/utils'
import { ImageIcon } from 'lucide-react'
export interface ImageButtonProps {
onSelect?: (file: File) => void
disabled?: boolean
}
const ImageButton = ({ onSelect, disabled }: ImageButtonProps) => {
const handleClick = () => {
const input = createElement<HTMLInputElement>(`<input type="file" accept="image/png,image/jpeg,image/webp" />`)
input.addEventListener(
'change',
async (e: Event) => {
onSelect?.((e.target as HTMLInputElement).files![0])
},
{ once: true }
)
input.click()
}
return (
<Button disabled={disabled} onClick={handleClick} variant="ghost" size="icon" className="dark:text-white">
<ImageIcon size={20} />
</Button>
)
}
ImageButton.displayName = 'ImageButton'
export default ImageButton

View file

@ -3,6 +3,7 @@ import { forwardRef, type ChangeEvent, CompositionEvent, type KeyboardEvent } fr
import { cn } from '@/utils' import { cn } from '@/utils'
import { Textarea } from '@/components/ui/Textarea' import { Textarea } from '@/components/ui/Textarea'
import { ScrollArea } from '@/components/ui/ScrollArea' import { ScrollArea } from '@/components/ui/ScrollArea'
import LoadingIcon from '@/assets/images/loading.svg'
export interface MessageInputProps { export interface MessageInputProps {
value?: string value?: string
@ -11,6 +12,7 @@ export interface MessageInputProps {
preview?: boolean preview?: boolean
autoFocus?: boolean autoFocus?: boolean
disabled?: boolean disabled?: boolean
loading?: boolean
onInput?: (e: ChangeEvent<HTMLTextAreaElement>) => void onInput?: (e: ChangeEvent<HTMLTextAreaElement>) => void
onKeyDown?: (e: KeyboardEvent<HTMLTextAreaElement>) => void onKeyDown?: (e: KeyboardEvent<HTMLTextAreaElement>) => void
onCompositionStart?: (e: CompositionEvent<HTMLTextAreaElement>) => void onCompositionStart?: (e: CompositionEvent<HTMLTextAreaElement>) => void
@ -33,7 +35,8 @@ const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
onCompositionStart, onCompositionStart,
onCompositionEnd, onCompositionEnd,
autoFocus, autoFocus,
disabled disabled,
loading
}, },
ref ref
) => { ) => {
@ -45,7 +48,12 @@ const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
autoFocus={autoFocus} autoFocus={autoFocus}
maxLength={maxLength} maxLength={maxLength}
className="box-border resize-none whitespace-pre-wrap break-words border-none bg-gray-50 pb-5 [field-sizing:content] [word-break:break-word] focus:ring-0 focus:ring-offset-0 dark:bg-slate-800" className={cn(
'box-border resize-none whitespace-pre-wrap break-words border-none bg-slate-100 pb-5 [field-sizing:content] [word-break:break-word] focus:ring-0 focus:ring-offset-0 dark:bg-slate-800',
{
'disabled:opacity-100': loading
}
)}
rows={2} rows={2}
value={value} value={value}
spellCheck={false} spellCheck={false}
@ -53,12 +61,21 @@ const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
onCompositionEnd={onCompositionEnd} onCompositionEnd={onCompositionEnd}
placeholder="Type your message here." placeholder="Type your message here."
onInput={onInput} onInput={onInput}
disabled={disabled} disabled={disabled || loading}
/> />
</ScrollArea> </ScrollArea>
<div className="absolute bottom-1 right-3 rounded-lg text-xs text-slate-400 dark:text-slate-50"> <div
className={cn('absolute bottom-1 right-3 rounded-lg text-xs text-slate-400', {
'opacity-50': disabled || loading
})}
>
{value?.length ?? 0}/{maxLength} {value?.length ?? 0}/{maxLength}
</div> </div>
{loading && (
<div className="absolute inset-0 flex items-center justify-center text-slate-800 after:absolute after:inset-0 after:backdrop-blur-xs dark:text-slate-100">
<LoadingIcon className="relative z-10 size-10"></LoadingIcon>
</div>
)}
</div> </div>
) )
} }

View file

@ -111,7 +111,7 @@ const AppButton: FC<AppButtonProps> = ({ className }) => {
> >
<div <div
className={cn( className={cn(
'absolute grid grid-rows-[repeat(2,minmax(0,2.5rem))] w-full justify-center items-center transition-all duration-500', 'absolute grid grid-rows-[repeat(2,minmax(0,2.5rem))] w-full justify-center items-center transition-all duration-300',
isDarkMode ? 'top-0' : '-top-10', isDarkMode ? 'top-0' : '-top-10',
isDarkMode ? 'bg-slate-950 text-white' : 'bg-white text-orange-400' isDarkMode ? 'bg-slate-950 text-white' : 'bg-white text-orange-400'
)} )}

View file

@ -15,10 +15,12 @@ import useTriggerAway from '@/hooks/useTriggerAway'
import { ScrollArea } from '@/components/ui/ScrollArea' import { ScrollArea } from '@/components/ui/ScrollArea'
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso' import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'
import UserInfoDomain from '@/domain/UserInfo' import UserInfoDomain from '@/domain/UserInfo'
import { cn, getRootNode, getTextSimilarity } from '@/utils' import { blobToBase64, cn, compressImage, getRootNode, getTextSimilarity } from '@/utils'
import { Avatar, AvatarFallback } from '@/components/ui/Avatar' import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
import { AvatarImage } from '@radix-ui/react-avatar' import { AvatarImage } from '@radix-ui/react-avatar'
import ToastDomain from '@/domain/Toast' import ToastDomain from '@/domain/Toast'
import ImageButton from '../../components/ImageButton'
import { nanoid } from 'nanoid'
const Footer: FC = () => { const Footer: FC = () => {
const send = useRemeshSend() const send = useRemeshSend()
@ -40,6 +42,7 @@ const Footer: FC = () => {
const shareAutoCompleteListRef = useShareRef(setAutoCompleteListRef, autoCompleteListRef) const shareAutoCompleteListRef = useShareRef(setAutoCompleteListRef, autoCompleteListRef)
const isComposing = useRef(false) const isComposing = useRef(false)
const virtuosoRef = useRef<VirtuosoHandle>(null) const virtuosoRef = useRef<VirtuosoHandle>(null)
const [inputLoading, setInputLoading] = useState(false)
const shareRef = useShareRef(inputRef, setRef) const shareRef = useShareRef(inputRef, setRef)
@ -47,6 +50,7 @@ const Footer: FC = () => {
* When inserting a username using the @ syntax, record the username's position information and the mapping relationship between the position information and userId to distinguish between users with the same name. * When inserting a username using the @ syntax, record the username's position information and the mapping relationship between the position information and userId to distinguish between users with the same name.
*/ */
const atUserRecord = useRef<Map<string, Set<[number, number]>>>(new Map()) const atUserRecord = useRef<Map<string, Set<[number, number]>>>(new Map())
const imageRecord = useRef<Map<string, string>>(new Map())
const updateAtUserAtRecord = useMemo( const updateAtUserAtRecord = useMemo(
() => (message: string, start: number, end: number, offset: number, atUserId?: string) => { () => (message: string, start: number, end: number, offset: number, atUserId?: string) => {
@ -102,11 +106,29 @@ const Footer: FC = () => {
const selectedUser = autoCompleteList.find((_, index) => index === selectedUserIndex)! const selectedUser = autoCompleteList.find((_, index) => index === selectedUserIndex)!
const handleSend = () => { // Replace the hash URL in ![Image](hash:${hash}) with base64 and update the atUserRecord.
const transformMessage = async (message: string) => {
let newMessage = message
const matchList = [...message.matchAll(/!\[Image\]\(hash:([^\s)]+)\)/g)]
matchList?.forEach((match) => {
const base64 = imageRecord.current.get(match[1])
if (base64) {
const base64Syntax = `![Image](${base64})`
const hashSyntax = match[0]
const startIndex = match.index
const endIndex = startIndex + base64Syntax.length - hashSyntax.length
newMessage = newMessage.replace(hashSyntax, base64Syntax)
updateAtUserAtRecord(newMessage, startIndex, endIndex, 0)
}
})
return newMessage
}
const handleSend = async () => {
if (!`${message}`.trim()) { if (!`${message}`.trim()) {
return send(toastDomain.command.WarningCommand('Message cannot be empty.')) return send(toastDomain.command.WarningCommand('Message cannot be empty.'))
} }
const transformedMessage = await transformMessage(message)
const atUsers = [...atUserRecord.current] const atUsers = [...atUserRecord.current]
.map(([userId, positions]) => { .map(([userId, positions]) => {
const user = userList.find((user) => user.userId === userId) const user = userList.find((user) => user.userId === userId)
@ -114,7 +136,7 @@ const Footer: FC = () => {
}) })
.filter(Boolean) .filter(Boolean)
send(roomDomain.command.SendTextMessageCommand({ body: message, atUsers })) send(roomDomain.command.SendTextMessageCommand({ body: transformedMessage, atUsers }))
send(messageInputDomain.command.ClearCommand()) send(messageInputDomain.command.ClearCommand())
} }
@ -211,6 +233,33 @@ const Footer: FC = () => {
}) })
} }
const handleInjectImage = async (file: File) => {
try {
setInputLoading(true)
const blob = await compressImage({ input: file, targetSize: 30 * 1024, outputType: 'image/webp' })
const base64 = await blobToBase64(blob)
const hash = nanoid()
const newMessage = `${message.slice(0, selectionEnd)}![Image](hash:${hash})${message.slice(selectionEnd)}`
const start = selectionStart
const end = selectionEnd + newMessage.length - message.length
updateAtUserAtRecord(newMessage, start, end, 0)
send(messageInputDomain.command.InputCommand(newMessage))
imageRecord.current.set(hash, base64)
requestIdleCallback(() => {
inputRef.current?.setSelectionRange(end, end)
inputRef.current?.focus()
})
} catch (error) {
send(toastDomain.command.ErrorCommand((error as Error).message))
} finally {
setInputLoading(false)
}
}
const handleInjectAtSyntax = (username: string) => { const handleInjectAtSyntax = (username: string) => {
const atIndex = message.lastIndexOf('@', selectionEnd - 1) const atIndex = message.lastIndexOf('@', selectionEnd - 1)
// Determine if there is a space before @ // Determine if there is a space before @
@ -285,11 +334,13 @@ const Footer: FC = () => {
ref={shareRef} ref={shareRef}
value={message} value={message}
onInput={handleInput} onInput={handleInput}
loading={inputLoading}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
maxLength={MESSAGE_MAX_LENGTH} maxLength={MESSAGE_MAX_LENGTH}
></MessageInput> ></MessageInput>
<div className="flex items-center"> <div className="flex items-center">
<EmojiButton onSelect={handleInjectEmoji}></EmojiButton> <EmojiButton onSelect={handleInjectEmoji}></EmojiButton>
<ImageButton disabled={inputLoading} onSelect={handleInjectImage}></ImageButton>
<Button className="ml-auto" 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>

View file

@ -3,7 +3,7 @@ import { type ChangeEvent } from 'react'
import { ImagePlusIcon } from 'lucide-react' import { ImagePlusIcon } from 'lucide-react'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/Avatar' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/Avatar'
import { Label } from '@/components/ui/Label' import { Label } from '@/components/ui/Label'
import { cn, compressImage } from '@/utils' import { blobToBase64, cn, compressImage } from '@/utils'
export interface AvatarSelectProps { export interface AvatarSelectProps {
value?: string value?: string
@ -31,15 +31,10 @@ const AvatarSelect = React.forwardRef<HTMLInputElement, AvatarSelectProps>(
* In chrome storage.sync, each key-value pair supports a maximum storage of 8kb * In chrome storage.sync, each key-value pair supports a maximum storage of 8kb
* and all key-value pairs support a maximum storage of 100kb. * and all key-value pairs support a maximum storage of 100kb.
*/ */
const blob = await compressImage({ input: file, targetSize: compressSize }) const blob = await compressImage({ input: file, targetSize: compressSize, outputType: 'image/webp' })
const reader = new FileReader() const base64 = await blobToBase64(blob)
reader.onload = (e) => { onSuccess?.(base64)
const base64 = e.target?.result as string onChange?.(base64)
onSuccess?.(base64)
onChange?.(base64)
}
reader.onerror = () => onError?.(new Error('Failed to read image file.'))
reader.readAsDataURL(blob)
} catch (error) { } catch (error) {
onError?.(error as Error) onError?.(error as Error)
} }
@ -63,7 +58,14 @@ const AvatarSelect = React.forwardRef<HTMLInputElement, AvatarSelectProps>(
<ImagePlusIcon size={30} className="text-slate-400 group-hover:text-slate-500" /> <ImagePlusIcon size={30} className="text-slate-400 group-hover:text-slate-500" />
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<input ref={ref} hidden disabled={disabled} type="file" accept="image/png,image/jpeg" onChange={handleChange} /> <input
ref={ref}
hidden
disabled={disabled}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={handleChange}
/>
</Label> </Label>
) )
} }

View file

@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<radialGradient id="a4" cx=".66" fx=".66" cy=".3125" fy=".3125" gradientTransform="scale(1.5)">
<stop offset="0" stop-color="currentColor"></stop><stop offset=".3" stop-color="currentColor" stop-opacity=".9"></stop>
<stop offset=".6" stop-color="currentColor" stop-opacity=".6"></stop>
<stop offset=".8" stop-color="currentColor" stop-opacity=".3"></stop>
<stop offset="1" stop-color="currentColor" stop-opacity="0"></stop>
</radialGradient>
<circle transform-origin="center" fill="none" stroke="url(#a4)" stroke-width="15" stroke-linecap="round" stroke-dasharray="200 1000" stroke-dashoffset="0" cx="100" cy="100" r="70">
<animateTransform type="rotate" attributeName="transform" calcMode="spline" dur="2" values="360;0" keyTimes="0;1" keySplines="0 0 1 1" repeatCount="indefinite"></animateTransform>
</circle>
<circle transform-origin="center" fill="none" opacity=".2" stroke="currentColor" stroke-width="15" stroke-linecap="round" cx="100" cy="100" r="70"></circle>
</svg>

After

Width:  |  Height:  |  Size: 1 KiB

10
src/utils/blobToBase64.ts Normal file
View file

@ -0,0 +1,10 @@
const blobToBase64 = (blob: Blob) => {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = (e) => resolve(e.target?.result as string)
reader.onerror = () => reject(new Error('Failed to read file.'))
reader.readAsDataURL(blob)
})
}
export default blobToBase64

View file

@ -60,7 +60,7 @@ const compress = async (
const compressImage = async (options: Options) => { const compressImage = async (options: Options) => {
const { input, targetSize, toleranceSize = -1024 } = options const { input, targetSize, toleranceSize = -1024 } = options
if (!['image/jpeg', 'image/png', 'image/webp'].includes(input.type)) { if (!['image/jpeg', 'image/png', 'image/webp'].includes(input.type)) {
throw new Error('Invalid input type, only support image/jpeg, image/png, image/webp') throw new Error('Only PNG, JPEG and WebP image are supported.')
} }
if (input.size <= targetSize) { if (input.size <= targetSize) {

View file

@ -14,3 +14,4 @@ export { default as generateRandomName } from './generateRandomName'
export { default as getCursorPosition } from './getCursorPosition' export { default as getCursorPosition } from './getCursorPosition'
export { default as getTextSimilarity } from './getTextSimilarity' export { default as getTextSimilarity } from './getTextSimilarity'
export { default as getRootNode } from './getRootNode' export { default as getRootNode } from './getRootNode'
export { default as blobToBase64 } from './blobToBase64'