Vue3或纯JS调用TinyMCE
简介
相关链接
TinyMCE 是 github 上 star 超 16k 的富文本编辑器
- 提供足够丰富且功能强大的免费版
- 可定制性强、可拓展性高
- 支持集成到 React/Vue/Angular 中
本文旨在快速应用,详细配置参阅官方文档
使用
官方提供了 TinyMCE Vue 组件,易于集成到 Vue3 项目中。推荐第二种方式
方式一:下载完整 TinyMCE 包作为静态资源
安装依赖:pnpm add tinymce @tinymce/tinymce-vue
参照上面的文档或者手动将 /node_modules/tinymce/ 拷贝至 /public/tinymce/
语言包可从 Tiny - Language Packages 下载,保存至 /public/tinymce/langs/,例如:'/public/tinymce/langs/zh-CN.js'
vue
<script setup>
import { ref } from 'vue'
import Editor from '@tinymce/tinymce-vue'
// 富文本内容
const reportContent = ref('<p>Hello!</p>')
// TinyMCE 配置
const config = {
base_url: '/tinymce',
suffix: '.min',
// anchor charmap insertdatetime visualblocks help wordcount
plugins: 'anchor lists advlist autolink charmap code media help',
toolbar: 'undo redo | styles | bold italic underline strikethrough | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link image',
height: 500,
language: 'zh_CN',
language_url: '/tinymce/langs/zh-CN.js',
}
</script>
<template>
<div class="editor-container" element-loading-text="初始化编辑器...">
<Editor
v-model="reportContent"
licenseKey="gpl"
tinymce-script-src="/tinymce/tinymce.min.js"
:init="config"
/>
</div>
</template>
<style scoped>
.editor-container {
overflow: auto;
}
.editor-container :deep(>textarea) {
position: absolute;
opacity: 0;
visibility: hidden;
overflow: hidden;
z-index: -1;
}
.editor-container :deep(.tox .tox-edit-area::before) {
border-color: var(--el-color-primary);
}
.editor-container :deep(.tox .tox-toolbar__primary) {
justify-content: center;
}
</style>方式二:正常引入
相比于第一种,版本更新方便,但需要手动配置这些功能:
- 手动引入工具类
- 引入主题样式文件并处理样式隔离
- 引入所需编辑器插件
- 安装
tinymce-i18n应用中文翻译
js
/**
* TinyMCE 富文本编辑工具类
* @see https://www.tiny.cloud/docs/tinymce/latest/vue-pm-bundle/
*/
// Add the @tinymce/tinymce-vue wrapper to the bundle
import Editor from '@tinymce/tinymce-vue'
// Content styles as inline strings (applied inside editor iframe via content_style)
import contentCss from 'tinymce/skins/content/default/content.min.css?inline'
import contentUiCss from 'tinymce/skins/ui/oxide/content.min.css?inline'
// Editor skin CSS - imported as string to avoid global pollution
import skinCss from 'tinymce/skins/ui/oxide/skin.min.css?inline'
// Ensure TinyMCE is imported to register the global variable required by other components
import 'tinymce/tinymce'
// DOM model
import 'tinymce/models/dom/model'
// Theme
import 'tinymce/themes/silver'
// Toolbar icons
import 'tinymce/icons/default'
// Import plugins
import 'tinymce/plugins/advlist'
import 'tinymce/plugins/autolink'
import 'tinymce/plugins/code'
import 'tinymce/plugins/fullscreen'
import 'tinymce/plugins/image'
import 'tinymce/plugins/table'
import 'tinymce/plugins/link'
import 'tinymce/plugins/lists'
import 'tinymce/plugins/searchreplace'
// Language pack (side-effect import registers zh-CN locale)
import 'tinymce-i18n/langs8/zh-CN.js'
export { contentCss, contentUiCss, skinCss }
// Export Editor component for use in Vue components
export default Editorvue
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import Editor, { contentCss, contentUiCss, skinCss } from './editor.js'
// 动态注入 skin CSS(仅作用于 .tox 前缀选择器,组件卸载时移除)
let skinStyleEl = null
onMounted(() => {
skinStyleEl = document.createElement('style')
skinStyleEl.setAttribute('data-tinymce-skin', '')
skinStyleEl.textContent = skinCss
document.head.appendChild(skinStyleEl)
})
onUnmounted(() => {
if (skinStyleEl) {
skinStyleEl.remove()
skinStyleEl = null
}
})
// 富文本内容
const reportContent = ref('<p>Hello!</p>')
// TinyMCE 内容样式(content CSS 注入 iframe 内部,不污染全局)
const contentStyle = `${contentCss}${contentUiCss}`
// TinyMCE 工具栏
const toolbar = [
['undo', 'redo'],
['styles'],
['bold', 'italic', 'underline', 'forecolor', 'backcolor', 'more_format', 'removeformat'],
['align_group', 'bullist', 'numlist'],
['link', 'image', 'table', 'hr'],
['code', 'fullscreen']
].map(g => g.filter(Boolean).join(' ')).join(' | ')
// TinyMCE Icons
const tinyMCEIcons = {
'more-txt-icon': `<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><path fill="currentColor" d="M10.672 2.611a1 1 0 0 0-1.843 0l-6.75 16a1 1 0 0 0 1.843.778L5.773 15h7.954l1.286 3.047q.237-.046.487-.047c.818 0 1.544.393 2 1a1 1 0 0 0-.078-.389zM12.884 13H6.617L9.75 5.573zM10.5 22a1.5 1.5 0 1 0 0-3a1.5 1.5 0 0 0 0 3m6.5-1.5a1.5 1.5 0 1 1-3 0a1.5 1.5 0 0 1 3 0m5 0a1.5 1.5 0 1 1-3 0a1.5 1.5 0 0 1 3 0"/></svg>`,
'paragram-icon': `<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><path fill="currentColor" d="M20 19a1 1 0 1 1 0 2H4a1 1 0 1 1 0-2zm0-4a1 1 0 1 1 0 2H4a1 1 0 1 1 0-2zm0-4a1 1 0 1 1 0 2h-6a1 1 0 1 1 0-2zm0-4a1 1 0 1 1 0 2h-6a1 1 0 1 1 0-2zm0-4a1 1 0 1 1 0 2h-6a1 1 0 1 1 0-2zm-13.735.089q.332-.054.68-.071a10 10 0 0 1 1.202 0q.315.018.654.071L12 12.742a2 2 0 0 1-.613.196q-.364.062-.622.062q-.406 0-.746-.17q-.33-.168-.53-.854l-1.26-4.052l-.257-.953l-.265-1.05q-.124-.525-.232-.97h-.091l-.373 1.424q-.2.792-.373 1.38l-1.6 5.111a4.5 4.5 0 0 1-.936.098a3.6 3.6 0 0 1-.563-.044a1.8 1.8 0 0 1-.456-.125L3 12.671zm.066 7.836h-.256a5 5 0 0 0-.307-.009a5 5 0 0 0-.257-.009H4.55l.688-1.852h1.069q.15-.009.29-.009q.15-.009.24-.009h1.21q.099 0 .224.01q.132 0 .257.008h1.118l.622 1.852h-.995a4 4 0 0 0-.248.01q-.15 0-.298.008H6.33Z"/></svg>`
}
// TinyMCE Group Buttons
const tinyMCEGroupToolbars = {
more_format: {
icon: 'more-txt-icon',
tooltip: '更多格式',
items: 'fontsize strikethrough superscript subscript'
},
align_group: {
icon: 'paragram-icon',
tooltip: '段落排版',
items: 'alignleft aligncenter alignright alignjustify | lineheight | outdent indent'
}
}
// TinyMCE 配置
const config = {
// base_url: '/tinymce',
// suffix: '.min',
skin: false,
content_css: false,
// anchor charmap insertdatetime visualblocks help wordcount
plugins: 'advlist autolink code fullscreen image table link lists searchreplace',
menubar: false,
statusbar: false,
toolbar,
height: 500,
language: 'zh_CN',
// language_url: '/tinymce/langs/zh-CN.js',
content_style: contentStyle,
setup: (editor) => {
// 注册图标
for (const [iconName, opt] of Object.entries(tinyMCEIcons)) {
editor.ui.registry.addIcon(iconName, opt)
}
// 注册分组按钮
for (const [gName, opt] of Object.entries(tinyMCEGroupToolbars)) {
editor.ui.registry.addGroupToolbarButton(gName, opt)
}
}
}
</script>
<template>
<div class="editor-container" element-loading-text="初始化编辑器...">
<Editor
v-model="reportContent"
licenseKey="gpl"
:init="config"
/>
</div>
</template>
<style scoped>
.editor-container {
overflow: auto;
}
.editor-container :deep(>textarea) {
position: absolute;
opacity: 0;
visibility: hidden;
overflow: hidden;
z-index: -1;
}
.editor-container :deep(.tox .tox-edit-area::before) {
border-color: var(--el-color-primary);
}
.editor-container :deep(.tox .tox-toolbar__primary) {
justify-content: center;
}
</style>图片上传配置
默认的图片上传需要输入指定url的图片,粘贴本地图片只会显示一个临时的 blob 链接。
TinyMCE 提供自定义上传处理,一般分两种,一是项目有自己的图床,对拷贝以及手动上传的图片,先上传到图床,然后将响应的url返回给编辑器。二是将图片转换成base64。这是官方文档 TinyMCE - Image and file options,扔给AI让它帮你写吧。。。
demo:图片压缩后转base64
接方法二示例,以下仅粘贴新增部分的配置
js
import Compressor from 'compressorjs'
const config = {
image_title: true,
image_dimensions: false,
automatic_uploads: false,
paste_data_images: true,
file_picker_types: 'image',
file_picker_callback: filePickerCallback,
}
/**
* 图片选择处理
* @see https://www.tiny.cloud/docs/tinymce/latest/image/#file_picker_callback
*/
function filePickerCallback(cb, _value, _meta) {
const input = document.createElement('input')
input.setAttribute('type', 'file')
input.setAttribute('accept', 'image/*')
input.addEventListener('change', async (e) => {
const file = e.target.files[0]
try {
// 压缩图片
const { file: compressedFile, base64 } = await getCompressedImg(file)
/*
Note: Now we need to register the blob in TinyMCEs image blob
registry. In the next release this part hopefully won't be
necessary, as we are looking to handle it internally.
*/
const uri = registerImageBlob(compressedFile, base64.split(',')[1])
// call the callback and populate the Title field with the file name
cb(uri, { title: compressedFile.name })
} catch (err) {
console.error(err)
}
})
input.click()
}
/**
* 压缩图片
* @param {File} file 图片文件
* @returns {Promise<{file: File, base64: string}>}
*/
function getCompressedImg(file) {
return new Promise((resolve, reject) => {
// eslint-disable-next-line no-new
new Compressor(file, {
quality: 0.8,
maxWidth: 1920,
strict: true,
success(compressedResult) {
// 压缩失败,则直接读取图片
getFileAndBase64(compressedResult.size >= file.size ? file : compressedResult).then(resolve, reject)
},
error(err) {
reject(new Error(`图片处理失败: ${err?.message || err}`))
}
})
})
}
/**
* 读取文件
* @param {File} file 文件
* @returns {Promise<{file: File, base64: string}>}
*/
function getFileAndBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve({ file, base64: reader.result })
reader.onerror = () => reject(new Error('读取文件失败'))
reader.readAsDataURL(file)
})
}图片上传相关配置项
image_title: 设置图片title(enable title field in the Image dialog)image_dimensions: 设置图片宽高(disable the image dimensions input field in the image dialog)automatic_uploads: 图片自动上传(enable automatic uploads of images represented by blob or data URIs)paste_data_images: 开启拖拽图片上传和本地图片选择file_picker_types: 文件上传类型file_picker_callback: 自定义文件上传处理
扩展
JS调用
去除 TinyMCE Vue组件外壳,直接引入js包,使用全局变量 tinymce 即可:
- 参照上面的方式一将库文件整个拷贝到项目中
- 引入js文件(/tinymce/tinymce.min.js)
- 使用init方法携带config参数(需指定
selector作为实例的css选择器)创建RTF实例 - 必要时手动销毁RTF实例
demo
在vue中示范,其他框架或原生JS中,通过 script 引入js包即可。
js
import { onMounted, onUnmounted } from 'vue'
let editorInstance = null
onMounted(() => {
const script = document.createElement('script')
script.src = '/tinymce/tinymce.min.js'
script.onload = () => {
tinymce.init({
selector: '#mytextarea',
license_key: 'gpl',
language: 'zh_CN',
language_url: '/tinymce/langs/zh-CN.js',
skin_url: '/tinymce/skins/ui/oxide',
content_css: '/tinymce/skins/content/default/content.min.css',
plugins: 'advlist autolink lists link image charmap preview anchor searchreplace visualblocks code fullscreen insertdatetime media table codesample wordcount',
toolbar: 'undo redo | blocks | bold italic underline strikethrough | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link image media table codesample | code fullscreen',
height: 500,
branding: false,
promotion: false,
setup(editor) {
editorInstance = editor
}
})
}
document.head.appendChild(script)
})
onUnmounted(() => {
if (editorInstance) {
editorInstance.destroy()
editorInstance = null
}
})