前端组知识库

👍欢迎大家积极投稿交流👍

如果文档内容陈旧或者链接失效,请发现后及时同步,我将尽快修改

👇微信👇

图片
Skip to content

TinyMCE 使用说明

VUE 组件方式

预览:

示例 demo 源码

vue
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import TinymceEditor from '../components/tinymce/index.vue'

const editorRef = ref()
const content = ref(`hello world`)
const options = ref({
  // height: 600,
  images_upload_handler: async (blobInfo: any, progress: any) => {
    console.log('blobInfo', blobInfo)
    console.log('progress', progress)
    // 上传图片到服务器
    const formData = new FormData()
    formData.append('file', blobInfo.blob(), blobInfo.filename())
    const response = await fetch('/api/upload-image', {
      method: 'POST',
      body: formData,
    })
    const data = await response.json()
    console.log('上传成功', data)
    // 返回图片URL
    return data.url
  },
})

// const editor = ref<Editor>()
// 初始化编辑器
const editorOnInit = (editor: any) => {
  console.log('编辑器初始化完成', editor)
}

// 内容变更处理
const editorOnChange = (html: string, editor: any) => {
  console.log('编辑器内容发生改变', html, editor)
}

// 插入文本
const insertContent = (text: string) => {
  editorRef.value?.insertContent(text)
}
// 插入图片
const insertImage = (url: string) => {
  editorRef.value?.insertContent(`<p><img src="${url}" /></p>`)
}

// 获取HTML内容
const getContent = () => {
  const html = editorRef.value?.getContent() || ''
  ElMessageBox.alert(html, 'HTML内容', {
    customStyle: {
      maxWidth: '600px',
      whiteSpace: 'pre-wrap',
    },
  })
}
// 获取选中的内容
function getSelectedContent() {
  const editor = editorRef.value?.getEditor()

  // 添加编辑器对象的空值检查
  if (!editor) {
    ElMessage.error('编辑器实例不存在')
    return
  }

  try {
    const text = editor.selection.getContent()
    console.log('获取到的选中内容', editor, text)
    if (!text) {
      ElMessage.warning('当前未选中文本')
      return
    }

    ElMessageBox.alert(text, '选中内容')
  } catch (error) {
    console.error('操作选中内容时出错:', error)
  }
}

// 选中内容添加链接
function addLinkToSelectedText() {
  const editor = editorRef.value?.getEditor()
  if (!editor) {
    ElMessage.error('编辑器实例不存在')
    return
  }

  try {
    const text = editor.selection.getContent()
    console.log('获取到的选中内容', text)
    if (!text) {
      ElMessage.warning('当前未选中文本')
      return
    }

    // 使用字符串方法检查是否包含换行符,如果存在换行符说明有块级标签,不支持添加链接
    if (text.indexOf('\n') !== -1 || text.indexOf('\r') !== -1) {
      ElMessage.warning('请选中单行文本')
      return
    }

    ElMessageBox.prompt(text, '请输入链接地址')
      .then((url) => {
        editor.selection.setContent(`<a href="${url.value}">${text}</a>`)
      })
      .catch(() => {
        ElMessage.info('操作已取消')
      })
  } catch (error) {
    console.error('操作选中内容时出错:', error)
  }
}

// iframe 相关
const iframeRef = ref<HTMLIFrameElement>()
const useIframe = ref(true) // 控制是否使用 iframe 预览

// 更新 iframe 内容
const updateIframeContent = () => {
  if (!useIframe.value) return

  if (iframeRef.value) {
    const iframe = iframeRef.value
    const editor = editorRef.value?.getEditor()
    const contentCSS = editor?.contentCSS || []

    try {
      // 使用 blob URL 方式设置 iframe 内容,避免同源策略问题
      const fullHtml = `
        <!DOCTYPE html>
        <html>
        <head>
          <meta charset="utf-8">
          <meta name="viewport" content="width=device-width, initial-scale=1.0">
          <title>富文本预览</title>
          ${contentCSS
            .map((link: string) => `<link rel="stylesheet" href="${link}">`)
            .join('\n')}
          <style>
            body { font-size: 14px; font-family: "Microsoft YaHei"; }
            p { margin: 0 0 10px 0; }
            /* 添加其他必要的样式 */
          </style>
        </head>
        <body>
          ${content.value}
        </body>
        </html>
      `

      // 创建 blob 对象
      const blob = new Blob([fullHtml], { type: 'text/html;charset=utf-8' })

      // 创建临时 URL
      const blobUrl = URL.createObjectURL(blob)

      // 设置 iframe src
      iframe.src = blobUrl

      // 监听 iframe 加载完成,释放 blob URL
      iframe.onload = () => {
        URL.revokeObjectURL(blobUrl)
      }
    } catch (error) {
      console.error('设置 iframe 内容时出错:', error)

      // 如果 iframe 方式失败,切换到 v-html 方式
      useIframe.value = false
    }
  }
}

// 监听内容变化,更新 iframe
watch(content, () => {
  updateIframeContent()
})

// 组件挂载后初始化 iframe 内容
onMounted(() => {
  updateIframeContent()
})
</script>

<template>
  <el-space wrap class="mb-4">
    <el-button
      type="primary"
      @click="insertContent('<p>这个是手动插入的文本</p>')"
      >插入文本</el-button
    >
    <el-button
      type="primary"
      @click="insertImage('https://placehold.co/300x200?text=Hello+World')"
      >插入图片</el-button
    >
    <el-button type="primary" @click="getContent">获取HTML</el-button>
    <el-button type="primary" @click="getSelectedContent">
      获取选中内容
    </el-button>
    <el-button type="primary" @click="addLinkToSelectedText">
      选中内容添加链接
    </el-button>
  </el-space>

  <TinymceEditor
    ref="editorRef"
    v-model="content"
    :options="options"
    @init="editorOnInit"
    @change="editorOnChange"
  />

  预览:

  <!-- iframe 预览 -->
  <iframe
    v-if="useIframe"
    ref="iframeRef"
    class="mt-2 border border-solid border-gray-300 p-2 box-shadow-md rounded-md"
    width="100%"
    height="300"
    sandbox="allow-scripts"
    frameborder="0"
    scrolling="auto"
  ></iframe>

  <!-- iframe 预览失败时的回退方案 -->
  <div
    v-else
    class="mt-2 border border-solid border-gray-300 p-4 box-shadow-md rounded-md"
    v-html="content"
  ></div>
</template>
组件源码
vue
<script setup lang="ts">
// import type { EditorOptions } from 'tinymce'
import type { TinymceProps, TinymceEmits } from './types.d'

import { computed, onMounted, ref, watch } from 'vue'

import Editor from '@tinymce/tinymce-vue'

const props = withDefaults(defineProps<TinymceProps>(), {
  modelValue: '',
  disabled: false,
  placeholder: '请输入内容',
  editorId: () => `tinymce-${Date.now()}`,
  plugins: () => [
    'accordion',
    'advlist',
    'anchor',
    'autolink',
    'autosave',
    'charmap',
    'code',
    'codesample',
    'directionality',
    'emoticons',
    'fullscreen',
    'help',
    'image',
    'insertdatetime',
    'link',
    'lists',
    'media',
    'nonbreaking',
    'pagebreak',
    'preview',
    'save',
    'searchreplace',
    'table',
    'visualblocks',
    'visualchars',
    'wordcount',
    'formatpainter',
  ],
  menubar: 'file edit insert view format table tools help',
  toolbar:
    'undo redo fontsize fontfamily bold italic underline strikethrough forecolor backcolor link image media alignleft aligncenter alignright alignjustify ltr rtl outdent indent lineheight numlist bullist formatpainter removeformat charmap emoticons blockquote anchor pagebreak code preview fullscreen',
  contextmenu: () => [
    'link',
    'linkchecker',
    'image',
    'editimage',
    'lists',
    'configurepermanentpen',
    'spellchecker',
    'table',
  ], // 右键菜单
  options: () => ({}),
})

const emit = defineEmits<TinymceEmits>()

const editorRef = ref<any>(null)
const isInit = ref(false)

// 默认配置(只启用已导入的插件)
const defaultOptions: Partial<Record<string, any>> = {
  height: 500, // 默认高度
  branding: false, // 不显示右下角logo
  resize: true, // 可以调整大小
  contextmenu: props.contextmenu, // 右键菜单
  plugins: props.plugins,
  toolbar_mode: 'wrap', // wrap
  menubar: props.menubar, // 菜单栏
  toolbar: props.toolbar,
  language: 'zh_CN', // 语言标识
  font_size_formats:
    '10px 11px 12px 13px 14px 15px 16px 18px 20px 22px 24px 26px 28px 30px 32px 34px 36px 40px 48px',
  font_size_input_default_unit: 'px',
  font_family_formats:
    '微软雅黑=Microsoft YaHei,Helvetica Neue,PingFang SC,sans-serif;苹果苹方=PingFang SC,Microsoft YaHei,sans-serif;宋体=simsun,serif;仿宋体=FangSong,serif;黑体=SimHei,sans-serif;楷体="楷体";隶书="隶书";幼圆="幼圆";Andale Mono=andale mono,times;Arial=arial,helvetica,sans-serif;Arial Black=arial black,avant garde;Book Antiqua=book antiqua,palatino;Comic Sans MS=comic sans ms,sans-serif;Courier New=courier new,courier;Georgia=georgia,palatino;Helvetica=helvetica;Impact=impact,chicago;Symbol=symbol;Tahoma=tahoma,arial,helvetica,sans-serif;Terminal=terminal,monaco;Times New Roman=times new roman,times;Trebuchet MS=trebuchet ms,geneva;Verdana=verdana,geneva;Webdings=webdings;Wingdings=wingdings;',
  line_height_formats: '1 1.5 1.6 1.75 2 2.5 3 4 5',
  content_style:
    'body { font-size: 14px; font-family: "Microsoft YaHei"; } p { margin: 0 0 10px 0; }', // 设置默认字体大小为16px
  placeholder: props.placeholder,
  // 配置图片上传处理函数
  images_upload_handler: async (blobInfo: any, progress: any): Promise<any> => {
    // 默认处理,可以在options中覆盖
    showMsg('请配置图片上传处理函数')
  },
}

function showMsg(msg: string) {
  // 安全地显示消息,避免编辑器未初始化时的错误
  if (editorRef.value && isInit.value) {
    try {
      const editor = editorRef.value.getEditor(props.editorId)
      if (editor && editor.windowManager) {
        return new Promise((resolve) => {
          editor.windowManager.alert(msg, () => {
            resolve(msg)
          })
        })
      }
    } catch (error) {
      console.error('显示消息失败:', error)
    }
  }
  console.warn(msg)
  return Promise.resolve(msg)
}

// 合并用户配置和默认配置
const editorOptions = computed(() => {
  const mergedOptions = { ...defaultOptions, ...props.options }

  // 如果是禁用状态,设置为只读
  if (props.disabled) {
    mergedOptions.readonly = true
  }

  return mergedOptions
})

// 编辑器初始化
const handleInit = (editor: any) => {
  isInit.value = true
  emit('init', editor)
}

// 内容变更处理
const handleChange = (editor: any) => {
  emit('change', content.value, editor)
}

const content = ref()
watch(
  () => props.modelValue,
  (newVal) => {
    content.value = newVal
  },
  { deep: true, immediate: true }
)

watch(
  () => content.value,
  (newVal) => {
    emit('update:modelValue', newVal)
  },
  { deep: true, immediate: true }
)

// 组件挂载后,如果有初始值,同步到编辑器
onMounted(() => {
  // tinymce.init({})
  if (props.modelValue && isInit.value && editorRef.value) {
    editorRef.value.setContent(props.modelValue)
  }
})

// 获取编辑器实例
function getEditor() {
  return editorRef.value.getEditor(props.editorId)
}

// 设置内容
function setContent(content: string) {
  getEditor().setContent(content)
}

// 插入内容
function insertContent(content: string) {
  getEditor().insertContent(content)
}

// 获取内容
function getContent() {
  return getEditor().getContent()
}

// 重置编辑器内容
function resetContent() {
  getEditor().resetContent()
}

// 聚焦编辑器
function focus() {
  getEditor().focus()
}

// 暴露方法
defineExpose({
  // 获取编辑器实例
  getEditor,
  // 设置内容
  setContent,
  // 插入内容
  insertContent,
  // 获取内容
  getContent,
  // 重置编辑器内容
  resetContent,
  // 聚焦编辑器
  focus,
})
</script>

<template>
  <div class="tinymce-editor">
    <Editor
      ref="editorRef"
      v-model="content"
      licenseKey="gpl"
      api-key="no-api-key"
      tinymce-script-src="https://cdn.hnny.top/tinymce/8.3.2/tinymce.min.js"
      :init="editorOptions"
      @init="handleInit"
      @change="handleChange"
    ></Editor>
  </div>
</template>

<style scoped lang="scss">
.tinymce-editor {
  :deep(.tox-editor-header) {
    display: block;

    .tox-promotion {
      display: none;
    }
  }

  // :deep(.tox-promotion) {
  //   display: none;
  // }
}
</style>
ts
/**
 * Tinymce 组件属性
 */
export interface TinymceProps {
  modelValue: string
  disabled?: boolean
  placeholder?: string
  editorId?: string
  menubar?: boolean | string
  toolbar?: boolean | string | string[] | Array<{
    name?: string;
    label?: string;
    items: string[];
  }>
  plugins?: string | string[]
  contextmenu?: string[]
  options?: Partial<Record<string, any>>
}

/**
 * Tinymce 组件事件
 */
export interface TinymceEmits {
  (e: 'update:modelValue', value: string): void
  (e: 'init', value: any): void
  (e: 'change', value: any, editor: any): void
}

上次更新于: