import type React from 'react'; import { useEffect, useRef, useCallback, useMemo } from 'react'; import { Toast, useUIKit } from '@tencentcloud/uikit-base-component-react'; import { useEditor, EditorContent } from '@tiptap/react'; import cs from 'classnames'; import { ConversationType, View, useChatContext } from 'tuikit-atomicx-react/chat'; import { useChatUIState } from '../../../context/useChatUIState'; import { parseConversationDraftContent, serializeConversationDraftContent, } from '../../../utils/conversationDraft'; import { getLocalConversationDraft, setLocalConversationDraft, } from '../../../utils/conversationDraftStorage'; import { convertEditorContent, convertInputContentToEditorNode, } from '../../../utils/messageInput'; import { getSendErrorMessage } from '../utils/getSendErrorMessage'; import { createExtensions } from './EditorCore'; import classes from './TextEditor.module.scss'; import type { InputContent } from '../../../types/messageInput'; import type { OfflinePushInfo, SendMessagePayload } from 'tuikit-atomicx-react/chat'; interface TextEditorProps { autoFocus?: boolean | undefined; disabled?: boolean | undefined; disableMention?: boolean | undefined; placeholder?: string | undefined; maxLength?: number | undefined; prefix?: (() => React.ReactNode) | undefined; suffix?: (() => React.ReactNode) | undefined; className?: string | undefined; style?: React.CSSProperties | undefined; /** Passed from MessageInput prop; called before each message send to resolve offlinePushInfo. */ setOfflinePushInfo?: ((payload: SendMessagePayload) => OfflinePushInfo | undefined) | undefined; } function TextEditor({ autoFocus = true, disabled = false, disableMention = false, placeholder = '', maxLength, prefix, suffix, className = '', style = {}, setOfflinePushInfo, }: TextEditorProps) { const { t } = useUIKit(); const { activeConversationID, activeConversation, memberList, loadMembers, setConversationDraft, } = useChatContext(); const { setEditorInstance, sendInputMessage, enterTyping, leaveTyping, } = useChatUIState(); const activeConversationDraftRef = useRef(undefined); const activeConversationIDRef = useRef(undefined); const isProgrammaticUpdateRef = useRef(false); const disableMentionRef = useRef(disableMention); disableMentionRef.current = disableMention; useEffect(() => { if (activeConversation?.type === ConversationType.Group && memberList.length === 0) { loadMembers(); } }, [activeConversation?.type, activeConversationID, loadMembers, memberList.length]); const placeholderText = useMemo(() => placeholder ?? t('TUIChat.Enter a message'), [placeholder, t]); // Handle Enter key to send message const handleEnter = useCallback(() => { sendInputMessage(setOfflinePushInfo) .catch((error) => { Toast.error({ message: getSendErrorMessage(t, error), }); }); }, [sendInputMessage, setOfflinePushInfo]); // Create editor using Tiptap's official useEditor hook const editor = useEditor({ autofocus: autoFocus, editable: !disabled, editorProps: { attributes: { enterkeyhint: 'send', }, handleDOMEvents: { blur: () => { leaveTyping().catch(() => {}); return false; }, beforeinput: (_view, event) => { const inputEvent = event as InputEvent; if (inputEvent.inputType !== 'insertParagraph' || inputEvent.isComposing) { return false; } event.preventDefault(); handleEnter(); return true; }, }, }, extensions: createExtensions({ placeholder: placeholderText, maxLength, showPlaceholderOnlyWhenEditable: false, onEnter: handleEnter, isMentionEnabled: () => !disableMentionRef.current, }), onUpdate: () => { if (isProgrammaticUpdateRef.current) { return; } enterTyping().catch(() => {}); }, }, [autoFocus, disabled, enterTyping, handleEnter, leaveTyping, placeholderText]); useEffect(() => { if (!disableMention || !editor || editor.isDestroyed) { return; } editor.view.dispatch(editor.state.tr); }, [disableMention, editor]); useEffect(() => { if (!editor || disabled || !autoFocus) { return undefined; } const frameID = requestAnimationFrame(() => { editor.commands.focus('end'); }); return () => { cancelAnimationFrame(frameID); }; }, [autoFocus, disabled, editor]); // Reactive: disabled prop useEffect(() => { editor?.setEditable(!disabled); }, [disabled, editor]); // Reactive: placeholder // useEffect(() => { // if (!editor || !editor.view) { // return; // } // const placeholderExtension = editor.extensionManager.extensions.find( // ext => ext.name === 'placeholder', // ); // if (placeholderExtension) { // placeholderExtension.options.placeholder = placeholderText; // editor.view.updateState(editor.state); // } // }, []); // Reactive: maxLength useEffect(() => { if (!editor) { return; } const characterCountExtension = editor.extensionManager.extensions.find( ext => ext.name === 'characterCount', ); if (characterCountExtension) { characterCountExtension.options.limit = maxLength; } }, [maxLength, editor]); useEffect(() => { activeConversationDraftRef.current = activeConversation?.draft; }, [activeConversation?.draft]); useEffect(() => { activeConversationIDRef.current = activeConversationID; }, [activeConversationID]); const saveCurrentDraft = useCallback((conversationID: string) => { if (!editor) { return; } const content = convertEditorContent(editor.getJSON()); const draft = serializeConversationDraftContent(content, { imageText: `[${t('MessageInput.image')}]`, emojiText: emojiContent => emojiContent.text || emojiContent.key, }); setLocalConversationDraft(conversationID, draft).catch(() => {}); setConversationDraft(conversationID, draft).catch(() => {}); }, [editor, setConversationDraft, t]); const setEditorDraftContent = useCallback((content: string | InputContent[] | undefined) => { if (!editor) { return; } isProgrammaticUpdateRef.current = true; try { if (typeof content === 'string' || content === undefined) { editor.commands.setContent(content ?? '', true); } else { editor.commands.setContent(content.map(convertInputContentToEditorNode), true); } } finally { isProgrammaticUpdateRef.current = false; } if (autoFocus) { editor.commands.focus(); } }, [autoFocus, editor]); const restoreDraft = useCallback((draft: string | undefined, conversationID: string | undefined) => { if (!conversationID) { setEditorDraftContent(''); return; } getLocalConversationDraft(conversationID).then((localDraft) => { if (activeConversationIDRef.current !== conversationID) { return; } const content = parseConversationDraftContent(localDraft ?? draft); setEditorDraftContent(content); }); }, [setEditorDraftContent]); // Save draft only when leaving a conversation, then restore the next conversation draft. useEffect(() => { if (!editor) { return undefined; } restoreDraft(activeConversationDraftRef.current, activeConversationID); return () => { leaveTyping().catch(() => {}); if (activeConversationID) { saveCurrentDraft(activeConversationID); } }; }, [activeConversationID, editor, leaveTyping, restoreDraft, saveCurrentDraft]); useEffect(() => { if (!activeConversationID) { setEditorDraftContent(''); } }, [activeConversationID, setEditorDraftContent]); // Sync editor instance to global state after draft lifecycle hooks are registered. useEffect(() => { setEditorInstance(editor ?? null); return () => { leaveTyping().catch(() => {}); setEditorInstance(null); }; }, [editor, leaveTyping, setEditorInstance]); return ( {prefix?.() ?? null} {suffix?.() ?? null} ); } export { TextEditor, };