feat: support send image button
This commit is contained in:
parent
ca97c5e976
commit
a01a93f260
9 changed files with 148 additions and 21 deletions
34
src/app/content/components/ImageButton.tsx
Normal file
34
src/app/content/components/ImageButton.tsx
Normal 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
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
)}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
onSuccess?.(base64)
|
||||
onChange?.(base64)
|
||||
}
|
||||
reader.onerror = () => onError?.(new Error('Failed to read image file.'))
|
||||
reader.readAsDataURL(blob)
|
||||
const blob = await compressImage({ input: file, targetSize: compressSize, outputType: 'image/webp' })
|
||||
const base64 = await blobToBase64(blob)
|
||||
onSuccess?.(base64)
|
||||
onChange?.(base64)
|
||||
} 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>
|
||||
)
|
||||
}
|
||||
|
|
12
src/assets/images/loading.svg
Normal file
12
src/assets/images/loading.svg
Normal 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
10
src/utils/blobToBase64.ts
Normal 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
|
|
@ -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) {
|
||||
|
|
|
@ -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'
|
||||
|
|
Loading…
Reference in a new issue