"use client"; import { useEffect, useRef, useState } from "react"; import { EditorContent, useEditor } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import Image from "@tiptap/extension-image"; import Placeholder from "@tiptap/extension-placeholder"; import { ImageIcon, PaperclipIcon, SendHorizonalIcon } from "lucide-react"; import { generateUUID } from "@/lib/utils"; type UploadedImage = { url: string; filename?: string; }; type MessageEditorProps = { disabled?: boolean; uploadingAsset?: boolean; onSend: (html: string) => Promise; onUploadImage: (file: File) => Promise; onSendAttachment: (file: File) => Promise; }; export function MessageEditor({ disabled = false, uploadingAsset = false, onSend, onUploadImage, onSendAttachment, }: MessageEditorProps) { 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 isUploading = uploadingAsset || localUploading; 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, }), Image, Placeholder.configure({ placeholder: "输入消息,Enter 发送,Shift + Enter 换行", }), ], content: "", editorProps: { attributes: { class: "cs-agent-scrollbar min-h-12 max-h-40 overflow-y-auto px-1.5 py-1 text-[13px] leading-6 text-slate-900 outline-none [&_p]:m-0 [&_p+*]:mt-2 [&_img]:my-2 [&_img]:max-h-64 [&_img]:rounded-xl [&_img]:object-contain", }, 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 html = editor.getHTML(); if (!isMeaningfulHTML(html)) { return; } await onSendRef.current(html); editor.commands.clearContent(true); } async function handleSelectImage(event: React.ChangeEvent) { const file = event.target.files?.[0]; event.target.value = ""; if (!file || !editor || disabled || isUploading) { if (editor && shouldRestoreFocusRef.current) { requestAnimationFrame(() => { editor.commands.focus(); }); } return; } await insertUploadedImage(file); } async function insertUploadedImage(file: File) { if (!editor || disabled || isUploading) { return; } shouldRestoreFocusRef.current = true; const objectUrl = URL.createObjectURL(file); const placeholderId = `uploading-${generateUUID()}`; editor .chain() .focus() .setImage({ src: objectUrl, alt: file.name || "uploading-image", title: placeholderId, }) .run(); try { setLocalUploading(true); const uploaded = await onUploadImageRef.current(file); if (!uploaded?.url) { removeImageByTitle(editor, placeholderId); return; } replaceImageSourceByTitle( editor, placeholderId, uploaded.url, uploaded.filename || "image", ); } finally { setLocalUploading(false); URL.revokeObjectURL(objectUrl); requestAnimationFrame(() => { if (!disabled && shouldRestoreFocusRef.current) { editor.commands.focus(); } }); } } async function handleSelectAttachment( event: React.ChangeEvent, ) { const file = event.target.files?.[0]; event.target.value = ""; if (!file || disabled || isUploading) { if (editor && shouldRestoreFocusRef.current) { requestAnimationFrame(() => { editor.commands.focus(); }); } return; } shouldRestoreFocusRef.current = editor?.isFocused ?? true; setLocalUploading(true); try { await onSendAttachmentRef.current(file); } finally { setLocalUploading(false); requestAnimationFrame(() => { if (editor && !disabled && shouldRestoreFocusRef.current) { editor.commands.focus(); } }); } } return (

Enter 发送

); } 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(clipboardData: DataTransfer | null) { if (!clipboardData) { return null; } for (const item of Array.from(clipboardData.items)) { if (item.kind === "file" && item.type.startsWith("image/")) { return item.getAsFile(); } } return null; } function removeImageByTitle(editor: NonNullable>, title: string) { const { state } = editor; let targetPos: number | null = null; state.doc.descendants((node, pos) => { if (node.type.name === "image" && node.attrs.title === title) { targetPos = pos; return false; } return true; }); if (targetPos === null) { return; } editor.chain().focus().deleteRange({ from: targetPos, to: targetPos + 1 }).run(); } function replaceImageSourceByTitle( editor: NonNullable>, title: string, src: string, alt: string, ) { const { state, view } = editor; let targetPos: number | null = null; state.doc.descendants((node, pos) => { if (node.type.name === "image" && node.attrs.title === title) { targetPos = pos; return false; } return true; }); if (targetPos === null) { return; } const transaction = view.state.tr.setNodeMarkup(targetPos, undefined, { ...view.state.doc.nodeAt(targetPos)?.attrs, src, alt, title: "", }); view.dispatch(transaction); }