feat: 富文本编辑器
Some checks failed
coverage CI / build (push) Has been cancelled

This commit is contained in:
2025-09-24 15:47:28 +08:00
parent 12495b6d85
commit 82d043a414
8 changed files with 1160 additions and 35 deletions

View File

@@ -0,0 +1,262 @@
import { Editor } from '@tinymce/tinymce-react';
import { message } from 'antd';
import React, { useCallback, useState } from 'react';
import type { Editor as TinyMCEEditor } from 'tinymce';
export interface RichEditorProps {
value?: string;
onChange?: (content: string) => void;
height?: number;
placeholder?: string;
disabled?: boolean;
uploadConfig?: {
action: string;
headers?: Record<string, string>;
maxSize?: number;
acceptTypes?: string[];
data?: Record<string, any>;
};
showWordCount?: boolean;
maxWords?: number;
}
const RichEditor: React.FC<RichEditorProps> = ({
// 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<boolean>(false);
const uploadFile = useCallback(
async (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append('file', file);
if (uploadConfig.data) {
Object.keys(uploadConfig.data).forEach((key) => {
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) => {
xhr.setRequestHeader(key, uploadConfig.headers![key]);
});
}
xhr.open('POST', uploadConfig.action);
xhr.send(formData);
});
},
[uploadConfig],
);
const handleImageUpload = useCallback(
(blobInfo: any, _: (percent: number) => void): Promise<string> => {
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 = `<img src="${url}" alt="${file.name}" style="max-width: 100%; height: auto;" />`;
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],
);
return (
<Editor
apiKey="xt51qhzvj8q9mxzxytigtp0nu94v4rz7dska2dbqeilkxo99"
init={{
plugins: [
// Core editing features
'anchor',
'autolink',
'charmap',
'codesample',
'emoticons',
'link',
'lists',
'media',
'searchreplace',
'table',
'visualblocks',
'wordcount',
// Your account includes a free trial of TinyMCE premium features
// Try the most popular premium features until Oct 8, 2025:
'checklist',
'mediaembed',
'casechange',
'formatpainter',
'pageembed',
'a11ychecker',
'tinymcespellchecker',
'permanentpen',
'powerpaste',
'advtable',
'advcode',
'advtemplate',
'ai',
'uploadcare',
'mentions',
'tinycomments',
'tableofcontents',
'footnotes',
'mergetags',
'autocorrect',
'typography',
'inlinecss',
'markdown',
'importword',
'exportword',
'exportpdf',
],
menubar: false,
toolbar:
'undo redo | blocks | bold italic forecolor | ' +
'alignleft aligncenter | ' +
'alignright alignjustify | bullist numlist outdent indent customupload | ' +
'removeformat ',
tinycomments_mode: 'embedded',
tinycomments_author: 'Author name',
toolbar_mode: 'sliding',
mergetags_list: [
{ value: 'First.Name', title: 'First Name' },
{ value: 'Email', title: 'Email' },
],
ai_request: (
_: any,
respondWith: { string: (arg0: () => Promise<never>) => any },
) =>
respondWith.string(() =>
Promise.reject('See docs to implement AI Assistant'),
),
language: 'zh-CN',
// 其他配置
convert_urls: false,
remove_script_host: false,
uploadcare_public_key: '0ad3671d77f59c5756dd',
setup: (editor: TinyMCEEditor) => {
// 注册自定义上传按钮
editor.ui.registry.addButton('customupload', {
text: uploading ? '上传中...' : '上传',
icon: 'upload',
tooltip: '上传图片(支持多选)',
enabled: !disabled && !uploading,
onAction: () => {
handleCustomUpload(editor);
},
});
},
// 图片上传配置
images_upload_handler: handleImageUpload,
// 性能配置
browser_spellcheck: true,
contextmenu: 'link image table',
}}
initialValue="Welcome to TinyMCE!"
/>
);
};
export default React.memo(RichEditor);