// components/RichEditor/index.tsx import { Editor } from '@tinymce/tinymce-react'; import { message } from 'antd'; import React, { useCallback, useEffect, useState } from 'react'; import type { Editor as TinyMCEEditor } from 'tinymce'; import './index.less'; export interface RichEditorProps { value?: string; onChange?: (content: string) => void; height?: number; placeholder?: string; disabled?: boolean; uploadConfig?: { action: string; headers?: Record; maxSize?: number; acceptTypes?: string[]; data?: Record; }; showWordCount?: boolean; maxWords?: number; } const RichEditor: React.FC = ({ value = '', onChange, height = 400, placeholder = '请输入内容...', disabled = false, showWordCount = true, maxWords, uploadConfig = { action: '/api/upload/image', maxSize: 5, acceptTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], }, }) => { const [uploading, setUploading] = useState(false); const [wordCount, setWordCount] = useState(0); const [charCount, setCharCount] = useState(0); // 自定义字数统计函数 const countWords = (text: string) => { const plainText = text.replace(/<[^>]*>/g, ''); const cleanText = plainText.replace(/\s+/g, ' ').trim(); if (!cleanText) return { words: 0, characters: 0 }; const chineseChars = cleanText.match(/[\u4e00-\u9fa5]/g) || []; const englishWords = cleanText.match(/[a-zA-Z]+/g) || []; const numbers = cleanText.match(/\d+/g) || []; const words = chineseChars.length + englishWords.length + numbers.length; const characters = cleanText.length; return { words, characters }; }; const handleEditorChange = (content: string) => { onChange?.(content); if (showWordCount) { const { words, characters } = countWords(content); setWordCount(words); setCharCount(characters); if (maxWords && words > maxWords) { message.warning( `内容超出字数限制,当前 ${words} 字,限制 ${maxWords} 字`, ); } } }; useEffect(() => { if (showWordCount && value) { const { words, characters } = countWords(value); setWordCount(words); setCharCount(characters); } }, [value, showWordCount]); const uploadFile = useCallback( async (file: File): Promise => { return new Promise((resolve, reject) => { const formData = new FormData(); formData.append('file', file); if (uploadConfig.data) { Object.keys(uploadConfig.data).forEach((key) => { if (uploadConfig.data) formData.append(key, uploadConfig.data[key]); }); } const xhr = new XMLHttpRequest(); xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable) { const percent = Math.round((e.loaded / e.total) * 100); console.log(`上传进度: ${percent}%`); } }); xhr.addEventListener('load', () => { if (xhr.status === 200) { try { const response = JSON.parse(xhr.responseText); if (response.code === 200 && response.data?.url) { resolve(response.data.url); } else { reject(response.message || '上传失败'); } } catch (_error) { reject('响应解析失败'); } } else { reject(`上传失败: ${xhr.status}`); } }); xhr.addEventListener('error', () => { reject('网络错误'); }); if (uploadConfig.headers) { Object.keys(uploadConfig.headers).forEach((key) => { if (uploadConfig.headers) xhr.setRequestHeader(key, uploadConfig.headers[key]); }); } xhr.open('POST', uploadConfig.action); xhr.send(formData); }); }, [uploadConfig], ); const handleImageUpload = useCallback( (blobInfo: any, _: (percent: number) => void): Promise => { return new Promise((resolve, reject) => { try { setUploading(true); const file = blobInfo.blob(); uploadFile(file).then((url) => { resolve(url); message.success('图片上传成功'); }); } catch (error) { reject(error); message.error(typeof error === 'string' ? error : '上传失败'); } finally { setUploading(false); } }); }, [uploadFile], ); const handleCustomUpload = useCallback( (editor: TinyMCEEditor) => { const input = document.createElement('input'); input.type = 'file'; input.accept = uploadConfig.acceptTypes?.join(',') || 'image/*'; input.multiple = true; const handleFileChange = async (event: Event) => { const target = event.target as HTMLInputElement; const files = Array.from(target.files || []); if (files.length === 0) return; setUploading(true); try { for (const file of files) { const isValidType = uploadConfig.acceptTypes?.includes(file.type); if (!isValidType) { message.error(`文件 ${file.name} 类型不支持`); continue; } const isValidSize = file.size / 1024 / 1024 < (uploadConfig.maxSize || 5); if (!isValidSize) { message.error( `文件 ${file.name} 大小超过 ${uploadConfig.maxSize || 5}MB`, ); continue; } try { const url = await uploadFile(file); const imgHtml = `${file.name}`; editor.insertContent(imgHtml); message.success(`${file.name} 上传成功`); } catch (error) { message.error(`${file.name} 上传失败: ${error}`); } } } finally { setUploading(false); } }; input.addEventListener('change', handleFileChange); input.click(); }, [uploadConfig, uploadFile], ); // 全展开的工具栏配置 const editorConfig = { height, menubar: 'file edit view insert format tools table help', plugins: [ 'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview', 'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen', 'insertdatetime', 'media', 'table', 'help', 'wordcount', 'emoticons', 'template', 'codesample', 'hr', 'pagebreak', 'nonbreaking', 'toc', 'imagetools', 'textpattern', 'noneditable', 'quickbars', 'accordion', ], // 修改为全展开的工具栏配置 toolbar: [ // 第一行:撤销重做 + 格式选择 + 字体 'undo redo | blocks fontfamily fontsize', // 第二行:文本格式 'bold italic underline strikethrough subscript superscript | forecolor backcolor', // 第三行:对齐和列表 'alignleft aligncenter alignright alignjustify | bullist numlist outdent indent', // 第四行:插入功能 'link customupload image media table emoticons charmap insertdatetime hr pagebreak', // 第五行:高级功能 'codesample accordion blockquote | searchreplace visualblocks code', // 第六行:其他工具 'removeformat | fullscreen preview help', ].join(' | '), // 设置工具栏模式为换行显示,而不是滑动 toolbar_mode: 'wrap' as const, // 改为 wrap 模式,全部展开 // 工具栏分组,每组之间有分隔符 toolbar_groups: { history: { icon: 'undo', tooltip: '历史操作' }, formatting: { icon: 'bold', tooltip: '文本格式' }, alignment: { icon: 'align-left', tooltip: '对齐方式' }, indentation: { icon: 'indent', tooltip: '缩进' }, insert: { icon: 'plus', tooltip: '插入' }, tools: { icon: 'preferences', tooltip: '工具' }, }, font_family_formats: [ '微软雅黑=Microsoft YaHei,Helvetica Neue,PingFang SC,sans-serif', '苹方=PingFang SC,Microsoft YaHei,sans-serif', '宋体=SimSun,serif', '黑体=SimHei,sans-serif', '楷体=KaiTi,serif', 'Arial=arial,helvetica,sans-serif', 'Times New Roman=times new roman,times,serif', 'Courier New=courier new,courier,monospace', ].join(';'), fontsize_formats: '8px 9px 10px 11px 12px 14px 16px 18px 20px 22px 24px 26px 28px 36px 48px 72px', block_formats: [ '段落=p', '标题1=h1', '标题2=h2', '标题3=h3', '标题4=h4', '标题5=h5', '标题6=h6', '预格式化=pre', '地址=address', '代码=code', ].join(';'), // 字数统计配置 wordcount_countregex: /[\w\u2019\u4e00-\u9fa5]+/g, wordcount_cleanregex: /[0-9.(),;:!?%#$?\x27\x22_+=\\/-]*/g, // 颜色配置 color_map: [ '000000', '黑色', '993300', '深红色', '333300', '深黄色', '003300', '深绿色', '003366', '深青色', '000080', '深蓝色', '333399', '蓝色', '333333', '深灰色', '800000', '栗色', 'FF6600', '橙色', '808000', '橄榄色', '008000', '绿色', '008080', '青色', '0000FF', '蓝色', '666699', '灰蓝色', '808080', '灰色', 'FF0000', '红色', 'FF9900', '琥珀色', '99CC00', '黄绿色', '339966', '海绿色', '33CCCC', '青绿色', '3366FF', '蓝色', '800080', '紫色', '999999', '中灰色', 'FF00FF', '洋红色', 'FFCC00', '金色', 'FFFF00', '黄色', '00FF00', '酸橙色', '00FFFF', '水蓝色', '00CCFF', '天蓝色', '993366', '红紫色', 'FFFFFF', '白色', ], // 代码高亮配置 codesample_languages: [ { text: 'HTML/XML', value: 'markup' }, { text: 'JavaScript', value: 'javascript' }, { text: 'TypeScript', value: 'typescript' }, { text: 'CSS', value: 'css' }, { text: 'SCSS', value: 'scss' }, { text: 'Python', value: 'python' }, { text: 'Java', value: 'java' }, { text: 'C++', value: 'cpp' }, { text: 'C#', value: 'csharp' }, { text: 'PHP', value: 'php' }, { text: 'Ruby', value: 'ruby' }, { text: 'Go', value: 'go' }, { text: 'Rust', value: 'rust' }, { text: 'SQL', value: 'sql' }, { text: 'JSON', value: 'json' }, { text: 'Bash', value: 'bash' }, ], // 表格配置 table_default_attributes: { border: '1', }, table_default_styles: { 'border-collapse': 'collapse', width: '100%', }, table_class_list: [ { title: '无样式', value: '' }, { title: '简单表格', value: 'simple-table' }, { title: '条纹表格', value: 'striped-table' }, { title: '边框表格', value: 'bordered-table' }, ], // 链接配置 link_default_target: '_blank', link_assume_external_targets: true, link_context_toolbar: true, // 图片配置 image_advtab: true, image_caption: true, // 媒体配置 media_live_embeds: true, media_filter_html: false, content_style: ` body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; font-size: 14px; line-height: 1.6; margin: 16px; color: #333; } h1, h2, h3, h4, h5, h6 { margin-top: 24px; margin-bottom: 16px; font-weight: 600; line-height: 1.25; } h1 { font-size: 2em; color: #1a1a1a; } h2 { font-size: 1.5em; color: #2a2a2a; } h3 { font-size: 1.25em; color: #3a3a3a; } p { margin-bottom: 16px; } img { max-width: 100%; height: auto; border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); margin: 8px 0; } table { border-collapse: collapse; width: 100%; margin: 16px 0; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } table td, table th { border: 1px solid #e8e8e8; padding: 12px; text-align: left; } table th { background-color: #f8f9fa; font-weight: 600; color: #495057; } blockquote { border-left: 4px solid #1890ff; margin: 16px 0; padding: 8px 16px; background-color: #f6f8fa; color: #666; font-style: italic; } code { background-color: #f1f3f4; padding: 2px 4px; border-radius: 3px; font-family: 'Courier New', monospace; font-size: 0.9em; color: #d73a49; } pre { background-color: #f8f8f8; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow-x: auto; margin: 16px 0; } pre code { background: none; padding: 0; color: inherit; } `, placeholder, branding: false, elementpath: true, resize: 'both' as const, statusbar: showWordCount, // 图片上传配置 images_upload_handler: handleImageUpload, automatic_uploads: true, images_reuse_filename: true, images_upload_url: uploadConfig.action, images_upload_base_path: '', images_upload_credentials: true, // 粘贴配置 paste_data_images: true, paste_as_text: false, paste_webkit_styles: 'none', paste_merge_formats: true, paste_remove_styles_if_webkit: true, // 文件选择器配置 file_picker_types: 'image media', file_picker_callback: ( callback: (url: string, meta?: any) => void, // value: string, meta: any, ) => { if (meta.filetype === 'image') { const input = document.createElement('input'); input.type = 'file'; input.accept = uploadConfig.acceptTypes?.join(',') || 'image/*'; const handleChange = async (event: Event) => { const target = event.target as HTMLInputElement; const file = target.files?.[0]; if (!file) return; const isValidType = uploadConfig.acceptTypes?.includes(file.type); const isValidSize = file.size / 1024 / 1024 < (uploadConfig.maxSize || 5); if (!isValidType) { message.error('文件类型不支持'); return; } if (!isValidSize) { message.error(`文件大小不能超过 ${uploadConfig.maxSize || 5}MB`); return; } try { setUploading(true); const url = await uploadFile(file); callback(url, { alt: file.name }); message.success('图片上传成功'); } catch (error) { message.error('上传失败'); console.error('Upload error:', error); } finally { setUploading(false); } }; input.addEventListener('change', handleChange); input.click(); } }, // 快速工具栏 quickbars_selection_toolbar: 'bold italic | quicklink h2 h3 blockquote quickimage quicktable', quickbars_insert_toolbar: 'quickimage quicktable', // 文本模式 textpattern_patterns: [ { start: '*', end: '*', format: 'italic' }, { start: '**', end: '**', format: 'bold' }, { start: '#', format: 'h1' }, { start: '##', format: 'h2' }, { start: '###', format: 'h3' }, { start: '####', format: 'h4' }, { start: '#####', format: 'h5' }, { start: '######', format: 'h6' }, { start: '1. ', cmd: 'InsertOrderedList' }, { start: '* ', cmd: 'InsertUnorderedList' }, { start: '- ', cmd: 'InsertUnorderedList' }, ], // 初始化设置 setup: (editor: TinyMCEEditor) => { // 注册自定义上传按钮 editor.ui.registry.addButton('customupload', { text: uploading ? '上传中...' : '上传', icon: 'upload', tooltip: '上传图片(支持多选)', enabled: !disabled && !uploading, onAction: () => { handleCustomUpload(editor); }, }); // 监听内容变化,实时更新字数统计 editor.on('input change undo redo', () => { if (showWordCount) { const content = editor.getContent(); const { words, characters } = countWords(content); setWordCount(words); setCharCount(characters); } }); editor.on('init', () => { console.log('编辑器初始化完成'); // 初始化字数统计 if (showWordCount && value) { const { words, characters } = countWords(value); setWordCount(words); setCharCount(characters); } }); }, // 其他配置 convert_urls: false, remove_script_host: false, relative_urls: false, language: 'zh_CN', directionality: 'ltr' as const, // 高级配置 extended_valid_elements: 'script[src|async|defer|type|charset]', // 性能配置 browser_spellcheck: true, contextmenu: 'link image table', }; return (
{/* 自定义字数统计显示 */} {showWordCount && (
字数: {wordCount} {maxWords && ( maxWords ? 'over-limit' : ''}> /{maxWords} )} 字符: {charCount}
)} {uploading && (
上传中...
)}
); }; export default RichEditor;