Vue 项目中 TinyMCE 富文本编辑器的具体使用方法

代码教程

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>

七、性能优化与注意事项

(一)性能优化建议

  1. 按需加载插件:只加载实际需要的插件,减少加载时间

    const editorInit = {
      plugins: 'link image table', // 只包含需要的插件
      toolbar: 'link image table'
    };
    
  2. 延迟初始化:对于不在视口中的编辑器,可以延迟初始化

    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'));
    });
    
  3. 优化图片处理:限制图片大小,使用图片懒加载

    const editorInit = {
      images_dataimg_filter: (img) => {
        // 过滤过大的base64图片
        return img.length < 1000000; // 1MB
      },
      images_lazy_loading: true // 启用图片懒加载
    };
    

(二)注意事项

  1. 安全考虑:永远不要直接信任用户输入的内容,需要在服务器端进行过滤和验证

    // 服务器端示例:过滤危险标签和属性
    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']
      }
    });
    
  2. 国际化支持:根据用户区域加载相应的语言包

    import 'tinymce/langs/zh_CN';
    
    const editorInit = {
      language: 'zh_CN'
    };
    
  3. 错误处理:实现完善的错误处理机制

    const handleEditorError = (error) => {
      console.error('编辑器错误:', error);
      // 可以显示用户友好的错误提示
    };
    

八、总结

TinyMCE是一款功能强大的富文本编辑器,与Vue.js结合使用可以为用户提供出色的文本编辑体验。通过本文介绍的安装配置方法、基础使用技巧、高级配置选项以及组件封装方案,你可以在自己的Vue项目中快速集成TinyMCE,并根据实际需求进行定制和扩展。

无论是博客系统、评论回复功能还是内容管理系统,TinyMCE都能满足你的需求,帮助你构建出专业、易用的文本编辑功能。


代码获取方式

【夸克网盘】点击查看


关注我获取更多内容

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值