"use client" import { useEffect, useRef, useState, type ChangeEvent } from "react" import Placeholder from "@tiptap/extension-placeholder" import { EditorContent, useEditor } from "@tiptap/react" import StarterKit from "@tiptap/starter-kit" import { ImageIcon, MessageSquareTextIcon, PaperclipIcon, SendHorizonalIcon, SendIcon, } from "lucide-react" import { Button } from "@/components/ui/button" import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@/components/ui/command" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { buildSendableEditorHTML, hasUploadingEditorImages, markEditorImageUploadedByTitle, MessageImageExtension, removeEditorImageByTitle, revokeEditorObjectUrl, revokeEditorObjectUrls, setEditorImageUploadingByTitle, type UploadedEditorImage, } from "@/lib/im-editor-image" import { generateUUID } from "@/lib/utils" export type UploadedMessageEditorImage = UploadedEditorImage & { url: string } export type MessageEditorQuickReply = { id: number groupName?: string title: string content: string } type SharedMessageEditorVariant = "customer" | "agent" type SharedMessageEditorProps = { variant: SharedMessageEditorVariant disabled?: boolean uploadingAsset?: boolean manageLocalUploading?: boolean quickReplies?: { open: boolean loading: boolean items: MessageEditorQuickReply[] onOpenChange: (open: boolean) => void } onSend: (html: string) => Promise onUploadImage: (file: File) => Promise onSendAttachment: (file: File) => Promise } export function SharedMessageEditor({ variant, disabled = false, uploadingAsset = false, manageLocalUploading = false, quickReplies, onSend, onUploadImage, onSendAttachment, }: SharedMessageEditorProps) { const [localUploading, setLocalUploading] = useState(false) const imageInputRef = useRef(null) const attachmentInputRef = useRef(null) const onSendRef = useRef(onSend) const onUploadImageRef = useRef(onUploadImage) const onSendAttachmentRef = useRef(onSendAttachment) const shouldRestoreFocusRef = useRef(false) const objectUrlsRef = useRef>(new Set()) const uploadedImagesRef = useRef(new Map()) const isCustomer = variant === "customer" const isUploading = uploadingAsset || (manageLocalUploading && localUploading) useEffect(() => { const objectUrls = objectUrlsRef.current return () => { revokeEditorObjectUrls(objectUrls) } }, []) useEffect(() => { onSendRef.current = onSend }, [onSend]) useEffect(() => { onUploadImageRef.current = onUploadImage }, [onUploadImage]) useEffect(() => { onSendAttachmentRef.current = onSendAttachment }, [onSendAttachment]) const editor = useEditor({ immediatelyRender: false, extensions: [ StarterKit.configure({ heading: false, blockquote: false, codeBlock: false, bulletList: false, orderedList: false, horizontalRule: false, }), MessageImageExtension, Placeholder.configure({ placeholder: "输入消息,Enter 发送,Shift + Enter 换行", }), ], content: "", editorProps: { attributes: { class: getEditorClassName(variant), }, handleKeyDown: (_view, event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault() void handleSend() return true } return false }, handlePaste: (_view, event) => { if (disabled || isUploading) { return false } const imageFile = getClipboardImageFile(event.clipboardData) if (!imageFile) { return false } event.preventDefault() void insertUploadedImage(imageFile) return true }, }, }) useEffect(() => { if (!editor) { return } editor.setEditable(!disabled && !isUploading) }, [disabled, editor, isUploading]) useEffect(() => { if (!editor || disabled || isUploading || !shouldRestoreFocusRef.current) { return } requestAnimationFrame(() => { editor.commands.focus() }) }, [disabled, editor, isUploading]) async function handleSend() { if (!editor || disabled || isUploading) { return } const rawHTML = editor.getHTML() if (hasUploadingEditorImages(rawHTML, uploadedImagesRef.current)) { return } const html = buildSendableEditorHTML(rawHTML, uploadedImagesRef.current) if (!isMeaningfulHTML(html)) { return } await onSendRef.current(html) editor.commands.clearContent(true) revokeEditorObjectUrls(objectUrlsRef.current) uploadedImagesRef.current.clear() if (!isCustomer) { requestAnimationFrame(() => { editor.commands.focus("end") }) } } async function handleSelectImage(event: ChangeEvent) { const file = event.target.files?.[0] event.target.value = "" if (!file || !editor || disabled || isUploading) { restoreFocusIfNeeded() return } await insertUploadedImage(file) } async function insertUploadedImage(file: File) { if (!editor || disabled || isUploading) { return } shouldRestoreFocusRef.current = true const objectUrl = URL.createObjectURL(file) objectUrlsRef.current.add(objectUrl) const placeholderId = `uploading-${generateUUID()}` editor .chain() .focus() .setImage({ src: objectUrl, alt: file.name || "uploading-image", title: placeholderId, }) .run() setEditorImageUploadingByTitle(editor, placeholderId) try { setLocalUploading(true) const uploaded = await onUploadImageRef.current(file) if (!uploaded?.assetId || !uploaded.provider || !uploaded.storageKey) { removeEditorImageByTitle(editor, placeholderId) revokeEditorObjectUrl(objectUrlsRef.current, objectUrl) return } markEditorImageUploadedByTitle( editor, placeholderId, uploaded, uploadedImagesRef.current ) } finally { setLocalUploading(false) requestAnimationFrame(() => { if (!disabled && shouldRestoreFocusRef.current) { editor.commands.focus() } }) } } async function handleSelectAttachment(event: ChangeEvent) { const file = event.target.files?.[0] event.target.value = "" if (!file || disabled || isUploading) { restoreFocusIfNeeded() return } shouldRestoreFocusRef.current = editor?.isFocused ?? true try { setLocalUploading(true) await onSendAttachmentRef.current(file) } finally { setLocalUploading(false) requestAnimationFrame(() => { if (editor && !disabled && shouldRestoreFocusRef.current) { editor.commands.focus() } }) } } function handleInsertQuickReply(item: MessageEditorQuickReply) { if (!editor || disabled || isUploading) { return } if (!item.content.trim()) { return } editor.chain().focus().insertContent(item.content).run() quickReplies?.onOpenChange(false) } function restoreFocusIfNeeded() { if (editor && shouldRestoreFocusRef.current) { requestAnimationFrame(() => { editor.commands.focus() }) } } const editorContent = ( <> {isCustomer ? (
) : (
)}
{quickReplies ? ( event.preventDefault()} /> } > 暂无快捷回复 {quickReplies.items.map((item) => ( handleInsertQuickReply(item)} >
{item.groupName ? `${item.groupName} / ${item.title}` : item.title} {item.content}
))}
) : null}

Enter 发送

{isCustomer ? ( ) : ( )}
) if (isCustomer) { return (
{editorContent}
) } return (
{editorContent}
) } function getEditorClassName(variant: SharedMessageEditorVariant) { if (variant === "customer") { return "cs-agent-scrollbar min-h-12 max-h-40 overflow-y-auto px-1.5 py-1 text-sm leading-6 text-foreground outline-none [&_p]:m-0 [&_p+*]:mt-2 [&_.cs-agent-editor-image-wrap]:my-2 [&_.cs-agent-editor-image]:max-h-64 [&_.cs-agent-editor-image]:max-w-full [&_.cs-agent-editor-image]:rounded-lg [&_.cs-agent-editor-image]:object-contain [&_.cs-agent-editor-image-wrap-uploading_.cs-agent-editor-image]:opacity-55" } return "h-full min-h-12 max-h-[20vh] overflow-y-auto px-1.5 py-1 text-sm leading-6 text-foreground outline-none sm:max-h-none [&_.ProseMirror-focused]:outline-none [&_p]:m-0 [&_p+.cs-agent-editor-image-wrap]:mt-2 [&_.cs-agent-editor-image-wrap]:my-2 [&_.cs-agent-editor-image]:max-h-64 [&_.cs-agent-editor-image]:max-w-full [&_.cs-agent-editor-image]:rounded-md [&_.cs-agent-editor-image]:object-contain [&_.cs-agent-editor-image-wrap-uploading_.cs-agent-editor-image]:opacity-55 [&_p.is-editor-empty:first-child]:before:text-muted-foreground" } function getToolbarClassName(variant: SharedMessageEditorVariant) { if (variant === "customer") { return "mt-2 flex items-center justify-between" } return "flex items-center justify-between rounded-b-sm border-t border-border bg-card px-2 pt-1 pb-2" } function getIconButtonClassName(variant: SharedMessageEditorVariant) { if (variant === "customer") { return "text-muted-foreground hover:bg-muted hover:text-foreground" } return "size-8" } function isMeaningfulHTML(html: string) { const normalized = html .replace(/

<\/p>/g, "") .replace(/


<\/p>/g, "") .replace(/\s+/g, "") if (//i.test(normalized)) { return true } const plainText = normalized.replace(/<[^>]+>/g, "").trim() return plainText !== "" } function getClipboardImageFile(data: DataTransfer | null) { if (!data) { return null } for (const item of Array.from(data.items)) { if (item.kind === "file" && item.type.startsWith("image/")) { return item.getAsFile() } } return null }