首先,需要在项目中安装 React、JavaScript 和 React-Quill 相关的依赖包。可以使用 npm 或 yarn 进行安装:
npm install react react-dom react-quill quill quill-image-resize-module
或者使用 yarn:
yarn add react react-dom react-quill quill quill-image-resize-module
在 React 组件中引入 React-Quill,并进行基础配置:
import React, { useState, useRef } from 'react';
import ReactQuill from 'react-quill';
import 'react-quill/dist/quill.snow.css';
import ImageResize from 'quill-image-resize-module';
import Quill from 'quill';
Quill.register('modules/imageResize', ImageResize);
const CustomerSupportEditor = () => {
const [content, setContent] = useState('');
const quillRef = useRef(null);
const modules = {
toolbar: {
container: "#toolbar",
handlers: {
image: imageHandler,
pdf: pdfHandler,
video: videoHandler
}
},
imageResize: {
modules: ['Resize', 'DisplaySize']
}
};
const formats = [
'header', 'bold', 'italic', 'underline', 'strike', 'blockquote',
'list', 'bullet', 'indent',
'link', 'image', 'video', 'pdf', 'file'
];
const handleChange = (value) => {
setContent(value);
};
return (
<div>
<div id="toolbar">
<button className="ql-bold"></button>
<button className="ql-italic"></button>
<button className="ql-underline"></button>
<button className="ql-image"></button>
<button className="ql-pdf">PDF</button>
<button className="ql-video">Video</button>
</div>
<ReactQuill
ref={quillRef}
theme="snow"
value={content}
onChange={handleChange}
modules={modules}
formats={formats}
/>
<button onClick={handleSubmit}>发送</button>
</div>
);
};
export default CustomerSupportEditor;
以上代码展示了如何基本引入 React-Quill,并设置自定义工具栏。接下来,将详细介绍各个功能的扩展。
为了支持插入图片、PDF 和视频,需要在工具栏中添加对应的按钮,并为这些按钮定义处理函数。
在编辑器上方定义一个自定义工具栏:
<div id="toolbar">
<button className="ql-bold"></button>
<button className="ql-italic"></button>
<button className="ql-underline"></button>
<button className="ql-image"></button>
<button className="ql-pdf">PDF</button>
<button className="ql-video">Video</button>
</div>
为图片、PDF 和视频按钮分别定义处理函数:
function imageHandler() {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/*');
input.click();
input.onchange = async () => {
const file = input.files[0];
const url = await uploadFile(file);
const editor = quillRef.current.getEditor();
const range = editor.getSelection();
editor.insertEmbed(range.index, 'image', url);
};
}
function pdfHandler() {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'application/pdf');
input.click();
input.onchange = async () => {
const file = input.files[0];
const url = await uploadFile(file);
const editor = quillRef.current.getEditor();
const range = editor.getSelection();
editor.insertEmbed(range.index, 'pdf', url);
};
}
function videoHandler() {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'video/*');
input.click();
input.onchange = async () => {
const file = input.files[0];
const url = await uploadFile(file);
const editor = quillRef.current.getEditor();
const range = editor.getSelection();
editor.insertEmbed(range.index, 'video', url);
};
}
以上函数通过创建隐藏的文件输入框,让用户选择文件后上传,并将返回的 URL 嵌入到编辑器中。
处理文件上传需要与后端服务器交互,将用户上传的文件存储并返回可访问的 URL。以下是一个示例上传函数:
async function uploadFile(file) {
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/upload', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('上传失败');
}
const data = await response.json();
return data.url; // 假设后端返回 { url: 'uploaded_file_url' }
} catch (error) {
console.error('Error uploading file:', error);
return '';
}
}
请根据实际情况替换上传 URL 和返回数据结构。
Quill 默认不支持 PDF 的预览展示,可以通过自定义 Blot 来实现更好的集成:
import Quill from 'quill';
const BlockEmbed = Quill.import('blots/block/embed');
class PDFBlot extends BlockEmbed {
static create(url) {
const node = super.create();
node.setAttribute('src', url);
node.setAttribute('type', 'application/pdf');
node.setAttribute('width', '100%');
node.setAttribute('height', '500px');
return node;
}
static value(node) {
return node.getAttribute('src');
}
}
PDFBlot.blotName = 'pdf';
PDFBlot.tagName = 'iframe';
Quill.register(PDFBlot);
通过上述代码,我们定义了一个名为 'pdf' 的 Blot,用于在编辑器中嵌入 PDF 文件。
在上传文件前,建议对文件类型和大小进行验证,以防止不必要的文件上传和安全风险:
function handleFileSelection(file, type) {
const allowedTypes = {
image: ['image/jpeg', 'image/png', 'image/gif'],
pdf: ['application/pdf'],
video: ['video/mp4', 'video/webm'],
};
const maxSize = {
image: 5 * 1024 * 1024, // 5MB
pdf: 10 * 1024 * 1024, // 10MB
video: 50 * 1024 * 1024, // 50MB
};
if (!allowedTypes[type].includes(file.type)) {
alert(`不支持的文件类型:${file.type}`);
return false;
}
if (file.size > maxSize[type]) {
alert(`文件大小不能超过 ${maxSize[type] / (1024 * 1024)}MB`);
return false;
}
return true;
}
在上传文件前调用此函数,确保只上传符合要求的文件。
为了改善用户体验,可以在上传过程中显示上传进度:
async function uploadFile(file, onProgress) {
const formData = new FormData();
formData.append('file', file);
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/upload');
xhr.upload.onprogress = (event) => {
if (event.lengthComputable && onProgress) {
const percent = (event.loaded / event.total) * 100;
onProgress(percent);
}
};
xhr.onload = () => {
if (xhr.status === 200) {
const response = JSON.parse(xhr.responseText);
resolve(response.url);
} else {
reject(new Error('上传失败'));
}
};
xhr.onerror = () => reject(new Error('上传失败'));
xhr.send(formData);
});
}
在调用上传函数时,可以传入一个回调函数来更新进度条。
在将用户输入的 HTML 内容渲染到页面时,必须进行必要的过滤,以防止 XSS 攻击。可使用库如 DOMPurify 进行清理:
import DOMPurify from 'dompurify';
const sanitizedContent = DOMPurify.sanitize(content);
// 然后将 sanitizedContent 渲染到页面上
在发送消息到后端前,也应在后端进行相应的过滤和验证。
通过引入 quill-image-resize-module
,用户可以在编辑器中调整图片大小,提高内容的灵活性:
import ImageResize from 'quill-image-resize-module';
Quill.register('modules/imageResize', ImageResize);
const modules = {
toolbar: {
container: "#toolbar",
handlers: {
image: imageHandler,
pdf: pdfHandler,
video: videoHandler
}
},
imageResize: {
modules: ['Resize', 'DisplaySize']
}
};
在发送或展示消息时,提供附件的预览和删除功能,增强用户体验:
// 示例组件:显示已上传的附件
const AttachmentPreview = ({ attachments, onDelete }) => (
<div className="attachment-preview">
{attachments.map((attachment, index) => (
<div key={index} className="attachment-item">
{attachment.type === 'image' && <img src={attachment.url} alt="图片附件" />}
{attachment.type === 'pdf' && <iframe src={attachment.url} title="PDF附件"></iframe>}
{attachment.type === 'video' && <video src={attachment.url} controls></video>}
<button onClick={() => onDelete(index)}>删除</button>
</div>
))}
</div>
);
以下是整合以上所有功能的完整示例代码:
import React, { useState, useRef } from 'react';
import ReactQuill from 'react-quill';
import 'react-quill/dist/quill.snow.css';
import ImageResize from 'quill-image-resize-module';
import Quill from 'quill';
import DOMPurify from 'dompurify';
Quill.register('modules/imageResize', ImageResize);
const PDFBlot = () => {
const BlockEmbed = Quill.import('blots/block/embed');
class PDFEmbed extends BlockEmbed {
static create(url) {
const node = super.create();
node.setAttribute('src', url);
node.setAttribute('type', 'application/pdf');
node.setAttribute('width', '100%');
node.setAttribute('height', '500px');
return node;
}
static value(node) {
return node.getAttribute('src');
}
}
PDFEmbed.blotName = 'pdf';
PDFEmbed.tagName = 'iframe';
Quill.register(PDFEmbed);
};
PDFBlot();
const CustomerSupportEditor = () => {
const [content, setContent] = useState('');
const quillRef = useRef(null);
const [attachments, setAttachments] = useState([]);
const modules = {
toolbar: {
container: "#toolbar",
handlers: {
image: imageHandler,
pdf: pdfHandler,
video: videoHandler
}
},
imageResize: {
modules: ['Resize', 'DisplaySize']
}
};
const formats = [
'header', 'bold', 'italic', 'underline', 'strike', 'blockquote',
'list', 'bullet', 'indent',
'link', 'image', 'video', 'pdf'
];
const handleChange = (value) => {
setContent(value);
};
const uploadFile = async (file, type, onProgress) => {
if (!handleFileSelection(file, type)) return '';
try {
const url = await uploadFileWithProgress(file, onProgress);
return url;
} catch (error) {
alert('上传失败,请重试');
return '';
}
};
const uploadFileWithProgress = (file, onProgress) => {
const formData = new FormData();
formData.append('file', file);
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/upload');
xhr.upload.onprogress = (event) => {
if (event.lengthComputable && onProgress) {
const percent = (event.loaded / event.total) * 100;
onProgress(percent);
}
};
xhr.onload = () => {
if (xhr.status === 200) {
const response = JSON.parse(xhr.responseText);
resolve(response.url);
} else {
reject(new Error('上传失败'));
}
};
xhr.onerror = () => reject(new Error('上传失败'));
xhr.send(formData);
});
};
const handleFileSelection = (file, type) => {
const allowedTypes = {
image: ['image/jpeg', 'image/png', 'image/gif'],
pdf: ['application/pdf'],
video: ['video/mp4', 'video/webm'],
};
const maxSize = {
image: 5 * 1024 * 1024, // 5MB
pdf: 10 * 1024 * 1024, // 10MB
video: 50 * 1024 * 1024, // 50MB
};
if (!allowedTypes[type].includes(file.type)) {
alert(`不支持的文件类型:${file.type}`);
return false;
}
if (file.size > maxSize[type]) {
alert(`文件大小不能超过 ${maxSize[type] / (1024 * 1024)}MB`);
return false;
}
return true;
};
const imageHandler = () => {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/*');
input.click();
input.onchange = async () => {
const file = input.files[0];
if (!file) return;
const url = await uploadFile(file, 'image', (percent) => {
console.log(`图片上传进度:${percent}%`);
});
if (url) {
const editor = quillRef.current.getEditor();
const range = editor.getSelection();
editor.insertEmbed(range.index, 'image', url);
setAttachments([...attachments, { type: 'image', url }]);
}
};
};
const pdfHandler = () => {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'application/pdf');
input.click();
input.onchange = async () => {
const file = input.files[0];
if (!file) return;
const url = await uploadFile(file, 'pdf', (percent) => {
console.log(`PDF上传进度:${percent}%`);
});
if (url) {
const editor = quillRef.current.getEditor();
const range = editor.getSelection();
editor.insertEmbed(range.index, 'pdf', url);
setAttachments([...attachments, { type: 'pdf', url }]);
}
};
};
const videoHandler = () => {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'video/*');
input.click();
input.onchange = async () => {
const file = input.files[0];
if (!file) return;
const url = await uploadFile(file, 'video', (percent) => {
console.log(`视频上传进度:${percent}%`);
});
if (url) {
const editor = quillRef.current.getEditor();
const range = editor.getSelection();
editor.insertEmbed(range.index, 'video', url);
setAttachments([...attachments, { type: 'video', url }]);
}
};
};
const handleSubmit = () => {
const sanitizedContent = DOMPurify.sanitize(content);
// 将 sanitizedContent 发送到后端
fetch('/api/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: sanitizedContent })
})
.then(response => response.json())
.then(data => {
alert('消息发送成功');
setContent('');
setAttachments([]);
})
.catch(error => {
console.error('发送消息失败:', error);
alert('发送消息失败,请重试');
});
};
return (
<div>
<div id="toolbar">
<button className="ql-bold"></button>
<button className="ql-italic"></button>
<button className="ql-underline"></button>
<button className="ql-image"></button>
<button className="ql-pdf">PDF</button>
<button className="ql-video">Video</button>
</div>
<ReactQuill
ref={quillRef}
theme="snow"
value={content}
onChange={handleChange}
modules={modules}
formats={formats}
/>
<button onClick={handleSubmit} style="margin-top: 10px;">发送</button>
{attachments.length > 0 && (
<AttachmentPreview attachments={attachments} onDelete={(index) => {
const newAttachments = [...attachments];
newAttachments.splice(index, 1);
setAttachments(newAttachments);
}} />
)}
</div>
);
};
const AttachmentPreview = ({ attachments, onDelete }) => (
<div className="attachment-preview" style="margin-top: 20px;">
<h4 style="color: #7FA86E;">附件预览</h4>
<table border="1" cellpadding="10" cellspacing="0">
<thead>
<tr>
<th>类型</th>
<th>预览</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{attachments.map((attachment, index) => (
<tr key={index}>
<td>{attachment.type.toUpperCase()}</td>
<td>
{attachment.type === 'image' && <img src={attachment.url} alt="图片" width="100" />}
{attachment.type === 'pdf' && <a href={attachment.url} target="_blank">查看PDF</a>}
{attachment.type === 'video' && <video src={attachment.url} controls width="100" />}
</td>
<td><button onClick={() => onDelete(index)}>删除</button></td>
</tr>
))}
</tbody>
</table>
</div>
);
export default CustomerSupportEditor;
为了防止用户意外关闭页面导致内容丢失,可以实现自动保存功能:
useEffect(() => {
const savedContent = localStorage.getItem('customerSupportContent');
if (savedContent) {
setContent(savedContent);
}
}, []);
useEffect(() => {
const timer = setTimeout(() => {
localStorage.setItem('customerSupportContent', content);
}, 2000);
return () => clearTimeout(timer);
}, [content]);
如果需要支持多语言,可以集成 i18next 或其他国际化库,动态切换工具栏按钮的文本。
确保编辑器在不同设备和屏幕尺寸下表现良好,可以使用 CSS 媒体查询和 Flexbox 等布局技术进行优化。
检查后端上传接口是否正常工作,确保跨域设置正确,并在前端捕获并处理上传错误。
确保正确引入了 React-Quill 的 CSS 文件,并根据需要自定义样式覆盖默认设置。
确保 PDF 文件的 URL 是可访问的,并且浏览器支持在 iframe 中渲染 PDF。如果需要更好的预览效果,可以集成 PDF.js 等库。
通过以上步骤,您可以使用 React、JavaScript 和 React-Quill 创建一个功能强大的客服回复消息编辑器,支持文本、图片、PDF 和视频的混排。关键在于合理配置 React-Quill 的工具栏和模块,处理文件上传与嵌入,并优化用户体验与安全性。根据实际需求,您还可以进一步扩展和优化编辑器的功能,提升整体的使用效果。