代码教程
TinyMCE富文本编辑器在Vue中的使用
一、概述
TinyMCE是一款功能强大、高度可定制的富文本编辑器,被广泛应用于各种Web应用中。在Vue项目中集成TinyMCE可以为用户提供专业的文本编辑体验。本文将详细介绍如何在Vue项目中使用TinyMCE,并提供完整的应用实例。
二、安装与基本配置
(一)安装TinyMCE和Vue集成包
npm install @tinymce/tinymce-vue tinymce --save
# 或者
yarn add @tinymce/tinymce-vue tinymce
(二)引入TinyMCE资源
在项目的入口文件(如main.js)中引入TinyMCE的CSS文件:
// main.js
import 'tinymce/tinymce';
import 'tinymce/icons/default';
import 'tinymce/themes/silver';
import 'tinymce/plugins/paste';
import 'tinymce/plugins/link';
import 'tinymce/plugins/image';
import 'tinymce/plugins/table';
import 'tinymce/skins/ui/oxide/skin.min.css';
import 'tinymce/skins/ui/oxide/content.min.css';
import 'tinymce/skins/content/default/content.min.css';
(三)全局注册TinyMCE组件
// main.js
import { createApp } from 'vue';
import App from './App.vue';
import Editor from '@tinymce/tinymce-vue';
const app = createApp(App);
app.component('TinyMCE', Editor);
app.mount('#app');
三、基础使用方法
(一)简单示例
<template>
<div class="editor-container">
<TinyMCE
v-model="content"
:init="editorInit"
/>
<div class="preview" v-html="content"></div>
</div>
</template>
<script setup>
import { ref } from 'vue';
const content = ref('');
const editorInit = ref({
height: 500,
menubar: false,
plugins: [
'advlist', 'autolink', 'lists', 'link', 'image',
'charmap', 'preview', 'anchor', 'searchreplace',
'visualblocks', 'fullscreen', 'insertdatetime',
'media', 'table', 'code', 'help', 'wordcount'
],
toolbar: 'undo redo | blocks | bold italic backcolor | ' +
'alignleft aligncenter alignright alignjustify | ' +
'bullist numlist outdent indent | removeformat | help',
content_style: 'body { font-family:Helvetica,Arial,sans-serif; font-size:14px }'
});
</script>
(二)使用局部组件
<template>
<div class="editor-container">
<TinyMCE
v-model="content"
:init="editorInit"
@onInit="handleEditorInit"
@onChange="handleEditorChange"
/>
<button @click="saveContent">保存内容</button>
</div>
</template>
<script setup>
import { ref } from 'vue';
import Editor from '@tinymce/tinymce-vue';
const content = ref('');
const editorInit = ref({
height: 400,
language: 'zh_CN', // 设置中文界面
plugins: [
'link image code table lists media'
],
toolbar: 'undo redo | formatselect | ' +
'bold italic backcolor | alignleft aligncenter ' +
'alignright alignjustify | bullist numlist outdent indent | ' +
'removeformat | link image code | table media',
images_upload_url: '/api/upload', // 图片上传地址
automatic_uploads: true,
image_title: true,
file_picker_types: 'image',
file_picker_callback: (cb, value, meta) => {
const input = document.createElement('input');
input.setAttribute('type', 'image');
input.setAttribute('accept', 'image/*');
input.addEventListener('change', (e) => {
const file = e.target.files[0];
const reader = new FileReader();
reader.onload = () => {
const id = 'blobid' + (new Date()).getTime();
const blobCache = tinymce.activeEditor.editorUpload.blobCache;
const base64 = reader.result.split(',')[1];
const blobInfo = blobCache.create(id, file, base64);
blobCache.add(blobInfo);
cb(blobInfo.blobUri(), { title: file.name });
};
reader.readAsDataURL(file);
});
input.click();
}
});
const handleEditorInit = (editor) => {
console.log('编辑器初始化完成', editor);
};
const handleEditorChange = (e) => {
console.log('编辑器内容变化', e.target.getContent());
};
const saveContent = () => {
console.log('保存内容:', content.value);
// 这里可以将内容发送到服务器
};
</script>
四、高级配置与功能实现
(一)自定义插件
// customPlugin.js
export default (editor) => {
// 添加自定义按钮
editor.ui.registry.addButton('customButton', {
text: '自定义按钮',
onAction: () => {
editor.insertContent('<p>这是自定义按钮插入的内容</p>');
}
});
// 添加自定义菜单
editor.ui.registry.addMenuItem('customMenu', {
text: '自定义菜单',
context: 'tools',
onAction: () => {
editor.windowManager.open({
title: '自定义对话框',
body: {
type: 'panel',
items: [
{
type: 'input',
name: 'text',
label: '输入文本'
}
]
},
buttons: [
{
text: '插入',
onclick: 'submit'
},
{
text: '取消',
onclick: 'close'
}
],
onSubmit: (api) => {
const data = api.getData();
editor.insertContent(`<p>${data.text}</p>`);
api.close();
}
});
}
});
return {
getMetadata: () => ({
name: 'Custom Plugin',
url: 'https://example.com'
})
};
};
(二)在Vue中使用自定义插件
<template>
<div class="editor-container">
<TinyMCE
v-model="content"
:init="editorInit"
/>
</div>
</template>
<script setup>
import { ref } from 'vue';
import customPlugin from './customPlugin';
const content = ref('');
const editorInit = ref({
height: 400,
plugins: 'customPlugin', // 注册自定义插件
toolbar: 'customButton | customMenu', // 添加自定义按钮和菜单
setup: (editor) => {
editor.plugins.customPlugin = customPlugin(editor);
}
});
</script>
(三)图片上传功能
<template>
<div class="editor-container">
<TinyMCE
v-model="content"
:init="editorInit"
/>
</div>
</template>
<script setup>
import { ref } from 'vue';
const content = ref('');
const editorInit = ref({
height: 500,
plugins: 'image',
toolbar: 'image',
images_upload_url: '/api/upload',
// 图片上传处理
images_upload_handler: (blobInfo, success, failure) => {
const formData = new FormData();
formData.append('file', blobInfo.blob(), blobInfo.filename());
fetch('/api/upload', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
success(data.url);
})
.catch(error => {
failure('上传失败: ' + error.message);
});
}
});
</script>
五、组件封装方案
(一)基础封装组件
<!-- BaseEditor.vue -->
<template>
<div class="base-editor">
<TinyMCE
v-model="editorContent"
:init="editorInit"
@onInit="handleInit"
@onChange="handleChange"
@onBlur="handleBlur"
/>
</div>
</template>
<script setup>
import { ref, defineProps, defineEmits, watch } from 'vue';
const props = defineProps({
modelValue: {
type: String,
default: ''
},
height: {
type: [Number, String],
default: 400
},
language: {
type: String,
default: 'zh_CN'
},
uploadUrl: {
type: String,
default: ''
}
});
const emits = defineEmits(['update:modelValue', 'init', 'change', 'blur']);
const editorContent = ref(props.modelValue);
const editorInit = ref({
height: props.height,
language: props.language,
menubar: false,
plugins: [
'advlist', 'autolink', 'lists', 'link', 'image',
'charmap', 'preview', 'anchor', 'searchreplace',
'visualblocks', 'fullscreen', 'insertdatetime',
'media', 'table', 'code', 'help', 'wordcount'
],
toolbar: 'undo redo | formatselect | ' +
'bold italic backcolor | alignleft aligncenter ' +
'alignright alignjustify | bullist numlist outdent indent | ' +
'removeformat | link image | table media',
content_style: 'body { font-family:Helvetica,Arial,sans-serif; font-size:14px }',
// 图片上传配置
images_upload_url: props.uploadUrl,
automatic_uploads: true,
image_title: true,
// 图片选择回调
file_picker_types: 'image',
file_picker_callback: (cb, value, meta) => {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/*');
input.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const id = 'blobid' + (new Date()).getTime();
const blobCache = tinymce.activeEditor.editorUpload.blobCache;
const base64 = reader.result.split(',')[1];
const blobInfo = blobCache.create(id, file, base64);
blobCache.add(blobInfo);
cb(blobInfo.blobUri(), { title: file.name });
};
reader.readAsDataURL(file);
});
input.click();
}
});
const handleInit = (editor) => {
emits('init', editor);
};
const handleChange = (e) => {
editorContent.value = e.target.getContent();
emits('change', editorContent.value);
};
const handleBlur = () => {
emits('blur', editorContent.value);
};
// 监听外部值变化
watch(() => props.modelValue, (newVal) => {
if (newVal !== editorContent.value) {
editorContent.value = newVal;
}
});
// 监听内部值变化并通知外部
watch(editorContent, (newVal) => {
emits('update:modelValue', newVal);
});
</script>
<style scoped>
.base-editor {
width: 100%;
}
</style>
(二)使用封装组件
<template>
<div class="app-container">
<h1>内容编辑器</h1>
<div class="editor-controls">
<button @click="saveContent">保存</button>
<button @click="previewContent">预览</button>
</div>
<BaseEditor
v-model="content"
:height="500"
:uploadUrl="uploadUrl"
@init="onEditorInit"
@change="onEditorChange"
/>
<div v-if="showPreview" class="preview-container" v-html="content"></div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import BaseEditor from './components/BaseEditor.vue';
const content = ref('');
const showPreview = ref(false);
const uploadUrl = ref('/api/upload');
const onEditorInit = (editor) => {
console.log('编辑器初始化完成:', editor);
};
const onEditorChange = (newContent) => {
console.log('编辑器内容更新:', newContent);
};
const saveContent = () => {
console.log('保存内容:', content.value);
// 实现保存逻辑
};
const previewContent = () => {
showPreview.value = !showPreview.value;
};
</script>
六、应用实例
(一)博客文章编辑器
<template>
<div class="blog-editor">
<div class="editor-header">
<input
type="text"
v-model="article.title"
placeholder="文章标题"
class="title-input"
/>
<div class="editor-meta">
<div class="category-select">
<label>分类:</label>
<select v-model="article.category">
<option value="">请选择分类</option>
<option v-for="category in categories" :key="category.id" :value="category.id">
{{ category.name }}
</option>
</select>
</div>
<div class="tag-input">
<label>标签:</label>
<input
type="text"
v-model="tagInput"
placeholder="输入标签,用逗号分隔"
@keyup.enter="addTag"
/>
<div class="tags">
<span v-for="(tag, index) in article.tags" :key="index" class="tag">
{{ tag }}
<button @click="removeTag(index)">×</button>
</span>
</div>
</div>
</div>
</div>
<BaseEditor
v-model="article.content"
:height="600"
:uploadUrl="uploadUrl"
/>
<div class="editor-footer">
<button class="btn-draft" @click="saveAsDraft">保存草稿</button>
<button class="btn-publish" @click="publishArticle">发布文章</button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import BaseEditor from './components/BaseEditor.vue';
const article = ref({
title: '',
content: '',
category: '',
tags: [],
status: 'draft',
publishDate: null
});
const categories = ref([
{ id: 'tech', name: '技术' },
{ id: 'life', name: '生活' },
{ id: 'travel', name: '旅行' },
{ id: 'food', name: '美食' }
]);
const tagInput = ref('');
const uploadUrl = ref('/api/upload');
const addTag = () => {
if (tagInput.value.trim()) {
const tags = tagInput.value.split(',').map(tag => tag.trim()).filter(tag => tag);
article.value.tags = [...article.value.tags, ...tags];
tagInput.value = '';
}
};
const removeTag = (index) => {
article.value.tags.splice(index, 1);
};
const saveAsDraft = () => {
article.value.status = 'draft';
console.log('保存草稿:', article.value);
// 实现保存草稿逻辑
};
const publishArticle = () => {
if (!article.value.title) {
alert('请输入文章标题');
return;
}
if (!article.value.content) {
alert('请输入文章内容');
return;
}
article.value.status = 'published';
article.value.publishDate = new Date().toISOString();
console.log('发布文章:', article.value);
// 实现文章发布逻辑
};
</script>
<style scoped>
.blog-editor {
display: flex;
flex-direction: column;
height: 100vh;
}
.editor-header {
padding: 20px;
border-bottom: 1px solid #eee;
}
.title-input {
width: 100%;
font-size: 24px;
font-weight: bold;
border: none;
outline: none;
margin-bottom: 15px;
}
.editor-meta {
display: flex;
gap: 20px;
}
.category-select, .tag-input {
display: flex;
align-items: center;
gap: 10px;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-left: 10px;
}
.tag {
background-color: #f0f0f0;
padding: 3px 8px;
border-radius: 4px;
display: flex;
align-items: center;
gap: 5px;
}
.tag button {
background: none;
border: none;
cursor: pointer;
font-size: 10px;
color: #666;
}
.editor-footer {
padding: 15px 20px;
border-top: 1px solid #eee;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.btn-draft {
padding: 8px 15px;
background-color: #f0f0f0;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
}
.btn-publish {
padding: 8px 15px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style>
(二)评论回复系统
<template>
<div class="comment-system">
<div class="comment-list">
<div v-for="comment in comments" :key="comment.id" class="comment-item">
<div class="comment-header">
<img :src="comment.avatar" alt="用户头像" class="avatar" />
<div class="user-info">
<h4>{{ comment.username }}</h4>
<p class="comment-time">{{ comment.time }}</p>
</div>
</div>
<div class="comment-content" v-html="comment.content"></div>
<div class="comment-actions">
<button @click="replyToComment(comment.id)">回复</button>
<button>点赞 ({{ comment.likes }})</button>
</div>
<div v-if="showReplyBox === comment.id" class="reply-box">
<BaseEditor
v-model="replyContent"
:height="200"
:uploadUrl="uploadUrl"
/>
<div class="reply-actions">
<button @click="cancelReply">取消</button>
<button @click="submitReply(comment.id)">提交回复</button>
</div>
</div>
<div v-if="comment.replies && comment.replies.length > 0" class="replies">
<div v-for="reply in comment.replies" :key="reply.id" class="reply-item">
<div class="reply-header">
<img :src="reply.avatar" alt="用户头像" class="avatar" />
<div class="user-info">
<h4>{{ reply.username }}</h4>
<p class="comment-time">{{ reply.time }}</p>
</div>
</div>
<div class="reply-content" v-html="reply.content"></div>
<div class="reply-actions">
<button @click="replyToReply(comment.id, reply.id)">回复</button>
<button>点赞 ({{ reply.likes }})</button>
</div>
<div v-if="showReplyBox === reply.id" class="nested-reply-box">
<BaseEditor
v-model="replyContent"
:height="200"
:uploadUrl="uploadUrl"
/>
<div class="reply-actions">
<button @click="cancelReply">取消</button>
<button @click="submitReply(comment.id, reply.id)">提交回复</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="new-comment">
<h3>发表评论</h3>
<BaseEditor
v-model="newComment"
:height="300"
:uploadUrl="uploadUrl"
/>
<button @click="postComment">发布评论</button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import BaseEditor from './components/BaseEditor.vue';
const comments = ref([
{
id: 1,
username: '张三',
avatar: 'https://picsum.photos/200/200?random=1',
time: '2023-05-10 10:30',
content: '<p>这是一篇非常有价值的文章,我学到了很多东西。</p><p>特别是关于TinyMCE的配置部分,对我帮助很大。</p>',
likes: 12,
replies: [
{
id: 101,
username: '李四',
avatar: 'https://picsum.photos/200/200?random=2',
time: '2023-05-10 11:15',
content: '<p>谢谢分享,我也觉得这篇文章很实用。</p><p>请问作者是否可以分享更多关于自定义插件的内容?</p>',
likes: 5
}
]
},
{
id: 2,
username: '王五',
avatar: 'https://picsum.photos/200/200?random=3',
time: '2023-05-09 15:45',
content: '<p>我在使用TinyMCE时遇到了一个问题,当插入大量图片后,编辑器会变得很卡。</p><p>请问有什么优化建议吗?</p>',
likes: 8,
replies: []
}
]);
const newComment = ref('');
const replyContent = ref('');
const showReplyBox = ref(null);
const uploadUrl = ref('/api/upload');
const replyToComment = (commentId) => {
showReplyBox.value = commentId;
};
const replyToReply = (commentId, replyId) => {
showReplyBox.value = replyId;
};
const cancelReply = () => {
showReplyBox.value = null;
replyContent.value = '';
};
const submitReply = (commentId, replyId = null) => {
if (!replyContent.value.trim()) {
alert('回复内容不能为空');
return;
}
const newReply = {
id: Date.now(),
username: '我',
avatar: 'https://picsum.photos/200/200?random=4',
time: new Date().toLocaleString(),
content: replyContent.value,
likes: 0
};
if (replyId) {
// 回复回复
const commentIndex = comments.value.findIndex(c => c.id === commentId);
if (commentIndex !== -1) {
const replyIndex = comments.value[commentIndex].replies.findIndex(r => r.id === replyId);
if (replyIndex !== -1) {
// 可以实现嵌套回复逻辑
comments.value[commentIndex].replies.splice(replyIndex + 1, 0, newReply);
}
}
} else {
// 回复评论
const commentIndex = comments.value.findIndex(c => c.id === commentId);
if (commentIndex !== -1) {
if (!comments.value[commentIndex].replies) {
comments.value[commentIndex].replies = [];
}
comments.value[commentIndex].replies.push(newReply);
}
}
cancelReply();
};
const postComment = () => {
if (!newComment.value.trim()) {
alert('评论内容不能为空');
return;
}
const newCommentObj = {
id: Date.now(),
username: '我',
avatar: 'https://picsum.photos/200/200?random=4',
time: new Date().toLocaleString(),
content: newComment.value,
likes: 0,
replies: []
};
comments.value.unshift(newCommentObj);
newComment.value = '';
};
</script>
<style scoped>
.comment-system {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.comment-list {
margin-bottom: 30px;
}
.comment-item {
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid #eee;
}
.comment-header, .reply-header {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
margin-right: 10px;
}
.user-info h4 {
margin: 0;
font-size: 16px;
}
.comment-time {
font-size: 12px;
color: #666;
margin: 0;
}
.comment-content, .reply-content {
margin-bottom: 10px;
line-height: 1.6;
}
.comment-actions, .reply-actions {
display: flex;
gap: 15px;
font-size: 14px;
}
.comment-actions button, .reply-actions button {
background: none;
border: none;
color: #007bff;
cursor: pointer;
}
.reply-box, .nested-reply-box {
margin-top: 15px;
}
.reply-actions {
justify-content: flex-end;
margin-top: 10px;
}
.replies {
margin-top: 15px;
padding-left: 20px;
border-left: 2px solid #f0f0f0;
}
.reply-item {
margin-bottom: 15px;
padding-bottom: 15px;
border-bottom: 1px solid #f0f0f0;
}
.new-comment {
margin-top: 30px;
}
.new-comment h3 {
margin-bottom: 15px;
}
.new-comment button {
margin-top: 15px;
padding: 8px 15px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style>
七、性能优化与注意事项
(一)性能优化建议
-
按需加载插件:只加载实际需要的插件,减少加载时间
const editorInit = { plugins: 'link image table', // 只包含需要的插件 toolbar: 'link image table' };
-
延迟初始化:对于不在视口中的编辑器,可以延迟初始化
const shouldInitialize = ref(false); const handleIntersect = (entries) => { entries.forEach(entry => { if (entry.isIntersecting) { shouldInitialize.value = true; } }); }; onMounted(() => { const observer = new IntersectionObserver(handleIntersect); observer.observe(document.getElementById('editor-container')); });
-
优化图片处理:限制图片大小,使用图片懒加载
const editorInit = { images_dataimg_filter: (img) => { // 过滤过大的base64图片 return img.length < 1000000; // 1MB }, images_lazy_loading: true // 启用图片懒加载 };
(二)注意事项
-
安全考虑:永远不要直接信任用户输入的内容,需要在服务器端进行过滤和验证
// 服务器端示例:过滤危险标签和属性 const sanitizeHtml = require('sanitize-html'); const cleanHtml = sanitizeHtml(dirtyHtml, { allowedTags: ['b', 'i', 'em', 'strong', 'a', 'p', 'ul', 'ol', 'li', 'img'], allowedAttributes: { 'a': ['href', 'title'], 'img': ['src', 'alt'] } });
-
国际化支持:根据用户区域加载相应的语言包
import 'tinymce/langs/zh_CN'; const editorInit = { language: 'zh_CN' };
-
错误处理:实现完善的错误处理机制
const handleEditorError = (error) => { console.error('编辑器错误:', error); // 可以显示用户友好的错误提示 };
八、总结
TinyMCE是一款功能强大的富文本编辑器,与Vue.js结合使用可以为用户提供出色的文本编辑体验。通过本文介绍的安装配置方法、基础使用技巧、高级配置选项以及组件封装方案,你可以在自己的Vue项目中快速集成TinyMCE,并根据实际需求进行定制和扩展。
无论是博客系统、评论回复功能还是内容管理系统,TinyMCE都能满足你的需求,帮助你构建出专业、易用的文本编辑功能。
代码获取方式
关注我获取更多内容