From a01a93f260c3fefadb1ad1ce0369af3ea8c6b3f0 Mon Sep 17 00:00:00 2001 From: molvqingtai Date: Tue, 29 Oct 2024 10:22:21 +0800 Subject: [PATCH 1/2] feat: support send image button --- src/app/content/components/ImageButton.tsx | 34 ++++++++++++ src/app/content/components/MessageInput.tsx | 25 +++++++-- src/app/content/views/AppButton/index.tsx | 2 +- src/app/content/views/Footer/index.tsx | 59 +++++++++++++++++++-- src/app/options/components/AvatarSelect.tsx | 24 +++++---- src/assets/images/loading.svg | 12 +++++ src/utils/blobToBase64.ts | 10 ++++ src/utils/compressImage.ts | 2 +- src/utils/index.ts | 1 + 9 files changed, 148 insertions(+), 21 deletions(-) create mode 100644 src/app/content/components/ImageButton.tsx create mode 100644 src/assets/images/loading.svg create mode 100644 src/utils/blobToBase64.ts diff --git a/src/app/content/components/ImageButton.tsx b/src/app/content/components/ImageButton.tsx new file mode 100644 index 0000000..60e5e7f --- /dev/null +++ b/src/app/content/components/ImageButton.tsx @@ -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(``) + + input.addEventListener( + 'change', + async (e: Event) => { + onSelect?.((e.target as HTMLInputElement).files![0]) + }, + { once: true } + ) + + input.click() + } + + return ( + + ) +} + +ImageButton.displayName = 'ImageButton' + +export default ImageButton diff --git a/src/app/content/components/MessageInput.tsx b/src/app/content/components/MessageInput.tsx index 47df383..dd300ed 100644 --- a/src/app/content/components/MessageInput.tsx +++ b/src/app/content/components/MessageInput.tsx @@ -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) => void onKeyDown?: (e: KeyboardEvent) => void onCompositionStart?: (e: CompositionEvent) => void @@ -33,7 +35,8 @@ const MessageInput = forwardRef( onCompositionStart, onCompositionEnd, autoFocus, - disabled + disabled, + loading }, ref ) => { @@ -45,7 +48,12 @@ const MessageInput = forwardRef( 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( onCompositionEnd={onCompositionEnd} placeholder="Type your message here." onInput={onInput} - disabled={disabled} + disabled={disabled || loading} /> -
+
{value?.length ?? 0}/{maxLength}
+ {loading && ( +
+ +
+ )}
) } diff --git a/src/app/content/views/AppButton/index.tsx b/src/app/content/views/AppButton/index.tsx index e9196b6..b3ba407 100644 --- a/src/app/content/views/AppButton/index.tsx +++ b/src/app/content/views/AppButton/index.tsx @@ -111,7 +111,7 @@ const AppButton: FC = ({ className }) => { >
{ const send = useRemeshSend() @@ -40,6 +42,7 @@ const Footer: FC = () => { const shareAutoCompleteListRef = useShareRef(setAutoCompleteListRef, autoCompleteListRef) const isComposing = useRef(false) const virtuosoRef = useRef(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>>(new Map()) + const imageRecord = useRef>(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} >
+