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 { Textarea } from '@/components/ui/Textarea'
import { ScrollArea } from '@/components/ui/ScrollArea'
import LoadingIcon from '@/assets/images/loading.svg'
export interface MessageInputProps {
value?: string
@ -11,6 +12,7 @@ export interface MessageInputProps {
preview?: boolean
autoFocus?: boolean
disabled?: boolean
loading?: boolean
onInput?: (e: ChangeEvent<HTMLTextAreaElement>) => void
onKeyDown?: (e: KeyboardEvent<HTMLTextAreaElement>) => void
onCompositionStart?: (e: CompositionEvent<HTMLTextAreaElement>) => void
@ -33,7 +35,8 @@ const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
onCompositionStart,
onCompositionEnd,
autoFocus,
disabled
disabled,
loading
},
ref
) => {
@ -45,7 +48,12 @@ const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
onKeyDown={onKeyDown}
autoFocus={autoFocus}
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}
value={value}
spellCheck={false}
@ -53,12 +61,21 @@ const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
onCompositionEnd={onCompositionEnd}
placeholder="Type your message here."
onInput={onInput}
disabled={disabled}
disabled={disabled || loading}
/>
</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}
</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>
)
}

View file

@ -111,7 +111,7 @@ const AppButton: FC<AppButtonProps> = ({ className }) => {
>
<div
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 ? '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 { Virtuoso, VirtuosoHandle } from 'react-virtuoso'
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 { AvatarImage } from '@radix-ui/react-avatar'
import ToastDomain from '@/domain/Toast'
import ImageButton from '../../components/ImageButton'
import { nanoid } from 'nanoid'
const Footer: FC = () => {
const send = useRemeshSend()
@ -40,6 +42,7 @@ const Footer: FC = () => {
const shareAutoCompleteListRef = useShareRef(setAutoCompleteListRef, autoCompleteListRef)
const isComposing = useRef(false)
const virtuosoRef = useRef<VirtuosoHandle>(null)
const [inputLoading, setInputLoading] = useState(false)
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.
*/
const atUserRecord = useRef<Map<string, Set<[number, number]>>>(new Map())
const imageRecord = useRef<Map<string, string>>(new Map())
const updateAtUserAtRecord = useMemo(
() => (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 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()) {
return send(toastDomain.command.WarningCommand('Message cannot be empty.'))
}
const transformedMessage = await transformMessage(message)
const atUsers = [...atUserRecord.current]
.map(([userId, positions]) => {
const user = userList.find((user) => user.userId === userId)
@ -114,7 +136,7 @@ const Footer: FC = () => {
})
.filter(Boolean)
send(roomDomain.command.SendTextMessageCommand({ body: message, atUsers }))
send(roomDomain.command.SendTextMessageCommand({ body: transformedMessage, atUsers }))
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 atIndex = message.lastIndexOf('@', selectionEnd - 1)
// Determine if there is a space before @
@ -285,11 +334,13 @@ const Footer: FC = () => {
ref={shareRef}
value={message}
onInput={handleInput}
loading={inputLoading}
onKeyDown={handleKeyDown}
maxLength={MESSAGE_MAX_LENGTH}
></MessageInput>
<div className="flex items-center">
<EmojiButton onSelect={handleInjectEmoji}></EmojiButton>
<ImageButton disabled={inputLoading} onSelect={handleInjectImage}></ImageButton>
<Button className="ml-auto" size="sm" onClick={handleSend}>
<span className="mr-2">Send</span>
<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 { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/Avatar'
import { Label } from '@/components/ui/Label'
import { cn, compressImage } from '@/utils'
import { blobToBase64, cn, compressImage } from '@/utils'
export interface AvatarSelectProps {
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
* and all key-value pairs support a maximum storage of 100kb.
*/
const blob = await compressImage({ input: file, targetSize: compressSize })
const reader = new FileReader()
reader.onload = (e) => {
const base64 = e.target?.result as string
const blob = await compressImage({ input: file, targetSize: compressSize, outputType: 'image/webp' })
const base64 = await blobToBase64(blob)
onSuccess?.(base64)
onChange?.(base64)
}
reader.onerror = () => onError?.(new Error('Failed to read image file.'))
reader.readAsDataURL(blob)
} catch (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" />
</AvatarFallback>
</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>
)
}

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 { input, targetSize, toleranceSize = -1024 } = options
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) {

View file

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