前言
最近项目中在做协议相关功能,要求引入富文本编辑器,有一些功能需求点,包括锚点、表格、预览、从 word 中复制到富文本编辑器中保留相关格式等。
综合业务需求,我这边对 ckEditor5 和 tinymce 进行了尝试。ckEditor5 在接入插件时相对较麻烦,我这边想要封装成一个通用功能星组件,就尝试采用 源码中引入ckEditor5插件,该方式需要去修改底层相关配置,但是我这边得项目底层配置是经过封装的,在接入过程中碰到了难以处理的问题,转而去尝试引入 tinymce,就一感觉:真香!!!
首先,奉上文档:
- tinymce5 官方文档(英文): www.tiny.cloud/docs/quick-…
- tinymce5 中文翻译文档: tinymce.ax-z.cn/
一、准备工作
-
安装
npm i tinymce
我这里安装的版本是 5.10.2
安装 tinymce 时会安装所有开源的插件,在
node_modules/tinymce/plugins
下,在需要时直接引入就可以。然后,node_modules 中找到 tinymce 目录,将目录中 skins 文件夹复制到新建的public/tinymce 文件夹中,然后去下载相关语言包,下载地址,放到 public/tinymce/language 中,后续需要引入。
tinymce 有三种模式:经典模式(classic,默认)、行内模式(inline)、清爽模式(Distraction-free),这里介绍最常用的经典模式,其它的模式可自行查看文档。
二、编辑器配置
1. 基本配置
添加最基本的配置:
<template>
<textarea :id="tinymceId" />
</template>
<script lang="ts">
import { defineComponent, computed, onMounted, onBeforeUnmount, unref } from 'vue'
import type { Editor, RawEditorSettings } from 'tinymce'
import tinymce from 'tinymce/tinymce'
import 'tinymce/themes/silver'
import 'tinymce/icons/default/icons'
export default defineComponent({
setup(){
const tinymceId = ref<string>(UUID())
const editorRef = ref<Editor>()
const initOptions = computed(():RawEditorSettings => {
const publicPath = __webpack_public_path__
return {
selector: `#${tinymceId.value}`,
language_url: `${publicPath}tinymce/langs/zh_CN.js`,
language: 'zh_CN',
skin_url: `${publicPath}tinymce/skins/ui/oxide`,
content_css: `${publicPath}tinymce/skins/ui/oxide/content.min.css`,
}
})
onMounted(() => {
tinymce.init(initOptions.value)
})
onBeforeUnmount(() => {
destory()
})
function destory() {
if (tinymce !== null) {
tinymce?.remove?.(unref(initOptions).selector!)
}
}
return { tinymceId }
}
})
</script>
效果如下:
2. 编辑器初始化
在初始化 setup 的钩子中可以进行初始化的操作:
- 向编辑器中填写初始化内容
- 设置编辑器的 只读/编辑状态
- 监听编辑器的相关操作
const initOptions = computed(() => {
return {
// .....
setup: (editor: Editor) => {
editorRef.value = editor
editor.on('init', initSetup)
},
}
})
// 编辑器初始化
function initSetup() {
const editor = unref(editorRef)
if (!editor) {
return
}
const value = props.value || ''
editor.setContent(value)
bindModelHandlers(editor)
}
function setValue(editor, val: string, prevVal?: string) {
if (
editor
&& typeof val === 'string'
&& val !== prevVal
&& val !== editor.getContent()
) {
editor.setContent(val)
}
}
function bindModelHandlers(editor: any) {
watch(() => props.value,
(val: string, prevVal) => setValue(editor, val, prevVal),
{ immediate: true },
)
watch(
() => props.disabled,
val => {
editor.setMode(val ? 'readonly' : 'design')
},
{ immediate: true },
)
editor.on('change keyup undo redo', () => {
const content = editor.getContent()
emit('update:value', content)
emit('change', content)
})
}
3. 图片上传配置
使用 images_upload_handler
可自定义上传处理逻辑,该自定义函数需提供三个参数:blobInfo、成功回调、失败回调 和 上传进度。使用该配置,则无需使用其他上传配置选项
const initOptions = computed(() => {
return {
// .....
images_upload_handler: handleImgUpload
}
})
// 图片上传自定义逻辑
function handleImgUpload(blobInfo, success, failure, progress) {
var xhr, formData;
var file = blobInfo.blob();//转化为易于理解的file对象
xhr = new XMLHttpRequest();
xhr.withCredentials = false;
xhr.open('POST', '/demo/upimg.php');
xhr.onload = function() {
var json;
if (xhr.status != 200) {
failFun('HTTP Error: ' + xhr.status);
return;
}
json = JSON.parse(xhr.responseText);
if (!json || typeof json.location != 'string') {
failFun('Invalid JSON: ' + xhr.responseText);
return;
}
succFun(json.location);
};
formData = new FormData();
formData.append('file', file, file.name );
xhr.send(formData);
}
最终效果图:
4. 完整版本代码
注意:paste_retain_style_properties
属性可以保留复制过来的相关样式,比如要保留字体大小、颜色、背景颜色,可以将其配置为 paste_retain_style_properties: 'font-size color background background-color'
,如果要保留所有样式可以设置为 all
,但是这样会造成代码量很大,并且这个属性将在 6 版本中移除,谨慎使用。
<template>
<textarea :id="tinymceId" />
</template>
<script lang="ts">
import {
defineComponent, computed, onMounted, ref, PropType, unref, watch, onBeforeUnmount,
} from 'vue'
import type { Editor, RawEditorSettings } from 'tinymce'
import tinymce from 'tinymce/tinymce'
import 'tinymce/themes/silver'
import 'tinymce/icons/default/icons'
import 'tinymce/plugins/advlist'
import 'tinymce/plugins/anchor'
import 'tinymce/plugins/autolink'
import 'tinymce/plugins/autosave'
import 'tinymce/plugins/code'
import 'tinymce/plugins/codesample'
import 'tinymce/plugins/directionality'
import 'tinymce/plugins/fullscreen'
import 'tinymce/plugins/hr'
import 'tinymce/plugins/insertdatetime'
import 'tinymce/plugins/link'
import 'tinymce/plugins/lists'
import 'tinymce/plugins/image'
import 'tinymce/plugins/toc'
import 'tinymce/plugins/nonbreaking'
import 'tinymce/plugins/noneditable'
import 'tinymce/plugins/pagebreak'
import 'tinymce/plugins/paste'
import 'tinymce/plugins/preview'
import 'tinymce/plugins/print'
import 'tinymce/plugins/save'
import 'tinymce/plugins/searchreplace'
import 'tinymce/plugins/spellchecker'
import 'tinymce/plugins/tabfocus'
import 'tinymce/plugins/table'
import 'tinymce/plugins/template'
import 'tinymce/plugins/textpattern'
import 'tinymce/plugins/visualblocks'
import 'tinymce/plugins/visualchars'
import 'tinymce/plugins/wordcount'
import { plugins as initialPlugins, toolbar as initialToolbar, fontFormats } from './tinymce'
import { UUID } from 'uuid'
type Recordable<T = any> = Record<string, T>
export default defineComponent({
props: {
value: {
type: String,
},
disabled: {
type: Boolean,
default: false
},
options: {
type: Object as PropType<Partial<RawEditorSettings>>,
default: () => ({}),
},
toolbar: {
type: String,
default: initialToolbar,
},
plugins: {
type: Array as PropType<string[]>,
default: initialPlugins,
},
height: {
type: [Number, String] as PropType<string | number>,
required: false,
default: 400,
},
width: {
type: [Number, String] as PropType<string | number>,
required: false,
default: 'auto',
},
},
emits: ['change', 'update:value'],
setup(props, { emit }) {
const tinymceId = ref<string>(UUID())
const editorRef = ref<Editor>()
const initOptions = computed((): RawEditorSettings => {
const publicPath = __webpack_public_path__
const {
height, options, toolbar, plugins,
} = props
return {
selector: `#${tinymceId.value}`,
language_url: `${publicPath}tinymce/langs/zh_CN.js`,
language: 'zh_CN',
skin_url: `${publicPath}tinymce/skins/ui/oxide`,
content_css: `${publicPath}tinymce/skins/ui/oxide/content.min.css`,
images_upload_handler: handleImgUpload,
images_file_types: 'jpeg,jpg,png,gif,bmp,webp', // 准许的图片格式
convert_urls: false,
branding: false, // 隐藏品牌,隐藏状态栏中显示的“ Powered by Tiny ”链接
placeholder: '请输入内容', // 占位符
toolbar,
plugins,
height,
toolbar_mode: 'sliding',
toolbar_sticky: true,
paste_block_drop: true, // 禁用将内容拖放到编辑器中
paste_data_images: false, // 粘贴data格式的图像 谷歌浏览器无法粘贴
font_formats: fontFormats,
paste_retain_style_properties: 'color border border-left border-right border-bottom border-top', // MS Word 和类似 Office 套件产品保留样式
paste_webkit_styles: 'none', // 允许在 WebKit 中粘贴时要保留的样式
paste_tab_spaces: 2, // 将制表符转换成空格的个数
content_style: `
html, body { height:100%; }
img { max-width:100%; display:block;height:auto; }
a { text-decoration: none; }
p { line-height:1.6; margin: 0px; }
table { word-wrap:break-word; word-break:break-all;max-width:100%; border:none; border-color:#999; }
.mce-object-iframe { width:100%; box-sizing:border-box; margin:0; padding:0; }
ul,ol { list-style-position:inside; }
`,
...options,
setup: (editor: Editor) => {
editorRef.value = editor
editor.on('init', initSetup)
},
}
})
onMounted(() => {
tinymce.init(initOptions.value)
})
onBeforeUnmount(() => {
destory()
})
function destory() {
if (tinymce !== null) {
tinymce?.remove?.(unref(initOptions).selector!)
}
}
// 图片上传自定义逻辑
function handleImgUpload(blobInfo, success, failure, progress) {
console.log('blobInfo', blobInfo.blob(), blobInfo.filename())
const { type: fileType, name: fileName } = blobInfo.blob()
// xxxx 自定义上传逻辑
}
// 编辑器初始化
function initSetup() {
const editor = unref(editorRef)
if (!editor) {
return
}
const value = props.value || ''
editor.setContent(value)
bindModelHandlers(editor)
}
function setValue(editor: Recordable, val: string, prevVal?: string) {
if (
editor
&& typeof val === 'string'
&& val !== prevVal
&& val !== editor.getContent()
) {
editor.setContent(val)
}
}
function bindModelHandlers(editor: any) {
watch(
() => props.value,
(val: string, prevVal) => setValue(editor, val, prevVal),
{ immediate: true },
)
watch(
() => props.disabled,
val => {
editor.setMode(val ? 'readonly' : 'design')
},
{ immediate: true },
)
editor.on('change keyup undo redo', () => {
const content = editor.getContent()
emit('update:value', content)
emit('change', content)
})
}
return {
tinymceId,
}
},
})
</script>
tinymce.ts 文件里是 tinymce 的 plugins、toolbar、fontFormats 的配置,这里基本上使用了所有的开源插件,功能比较齐全
// tinymce.ts
// imagetools
export const plugins = [
'advlist anchor autolink code codesample directionality fullscreen hr insertdatetime link lists nonbreaking noneditable pagebreak paste preview print save searchreplace tabfocus template textpattern visualblocks visualchars wordcount table image toc',
]
export const toolbar = 'undo redo | bold italic underline strikethrough | fontselect fontsizeselect formatselect | toc alignleft aligncenter alignright alignjustify lineheight | outdent indent | numlist bullist | forecolor backcolor | pagebreak | charmap emoticons | fullscreen preview save print | hr link image | anchor pagebreak | insertdatetime | blockquote removeformat subscript superscript code codesample | searchreplace'
export const fontFormats = '微软雅黑=Microsoft YaHei,Helvetica Neue,PingFang SC,sans-serif;苹果苹方=PingFang SC,Microsoft YaHei,sans-serif;宋体=simsun,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,zapf dingbats'
三、 属性配置汇总
width: '100%', // 设置富文本编辑器宽度
height: '100%', // 设置富文本编辑器高度
menubar: false, // 设置富文本编辑器菜单, 默认true
branding: false, // 关闭底部官网提示 默认true
statusbar: true, // 显示底部状态栏 默认true
readonly: false, // 设置只读属性 默认 false
resize: false, // 调节编辑器大小 默认 true
branding: false, // 隐藏状态栏右下角显示的品牌
placeholder: '请输入内容', // 占位符
theme: 'silver', // 主题 必须引入
skin_url: '/tinymce/skins/ui/oxide', // 主题路径
icons: 'custom', // 自定义图标名称
icons_url: '/tinymce/icons/icons.js', // 自定义图标路径
language_url: '/tinymce/langs/zh_CN.js', // 中文化 默认为英文
language: 'zh_CN', // 设置富文本编辑器语言
content_css: `/tinymce/skins/content/default`, // 富文本编辑器内容区域样式
content_style: 'body, p{font-size: 12px}', // 为内容区编辑自定义css样式
plugins: ['autosave help textpattern lineheight'], // 插件配置
toolbar: 'fontselect styleselect fontsizeselect restoredraft undo redo | bold italic underline strikethrough subscript superscript removeformat forecolor backcolor lineheight align outdent indent help', // 工具栏配置
toolbar_mode: 'sliding', // sliding生效条件toolbar必须为字符串,且有'|'区分,不能为数组
toolbar_sticky: true, // 粘性工具栏 默认false (在向下滚动网页直到不再可见编辑器时,将工具栏和菜单停靠在屏幕顶部)
// 快速工具栏配置,需引入插件 quickbars
quickbars_selection_toolbar: 'bold italic underline strikethrough | link h2 h3 h4 blockquote', // 设置 快速选择 触发提供的工具栏 需引入插件 默认 'alignleft aligncenter alignright' 设置为false禁用
quickbars_insert_toolbar: 'quickimage quicktable', // 设置 快速插入 触发提供的工具栏 需引入插件quickbars 默认 quickimage quicktable 设置为false禁用
// font 相关配置
fontsize_formats: '12px 14px 16px 18px 20px 22px 24px 26px 36px 48px 56px', // 工具栏自定义字体大小选项
font_formats: "微软雅黑='微软雅黑'; 宋体='宋体'; 黑体='黑体'; 仿宋='仿宋'; 楷体='楷体'; 隶书='隶书'; 幼圆='幼圆'; 方正舒体='方正舒体'; 方正姚体='方正姚体'; 等线='等线'; 华文彩云='华文彩云'; 华文仿宋='华文仿宋'; 华文行楷='华文行楷'; 华文楷体='华文楷体'; 华文隶书='华文隶书'; Andale Mono=andale mono,times; Arial=arial; Arial Black=arial black;avant garde; Book Antiqua=book antiqua;palatino; Comic Sans MS=comic sans ms; Courier New=courier new;courier; Georgia=georgia; Helvetica=helvetica; Impact=impact;chicago; Symbol=symbol; Tahoma=tahoma;arial; sans-serif; Terminal=terminal,monaco; Times New Roman=times new roman,times; Trebuchet MS=trebuchet ms; Verdana=verdana;geneva; Webdings=webdings; Wingdings=wingdings", // 工具栏自定义字体选项
// autosave 插件配置,需引入插件 autosave
autosave_ask_before_unload: true, // 阻止有内容时浏览器阻塞行为, 默认 true
autosave_interval: '3s', // 设置自动保存为草稿时间 单位只能为s
autosave_prefix: `editor_${route.path}`, // 设置自动保存为草稿时前缀 本地localStorage中存储
autosave_retention: '300m', // 自动草稿的有效期 单位只能为m(分钟)
// image 相关配置,需引入插件image
images_upload_handler: (blobInfo, success, failure) => {
// 发送请求, 获取图片路径后, 将路径传给success
success('xxxx')
}, // 图片上传函数
image_advtab: true, // 为上传图片窗口添加高级属性
// paste 相关配置,需引入插件paste
paste_data_images: true, // 粘贴data格式的图像
paste_block_drop: true, // 禁用将内容拖放到编辑器中
paste_as_text: true, // 默认粘贴为文本
paste_retain_style_properties: 'color border', // MS Word 和类似 Office 套件产品保留样式
// template 内容模板配置,需引入插件template
templates: [{ title: '标题', description: '描述', content: '内容' }], // 内容模板
// 快速排版配置,需引入插件 textpattern
textpattern_patterns: [
{ start: '*', end: '*', format: 'italic' },
{ start: '**', end: '**', format: 'bold' },
{ start: '#', format: 'h1' },
{ start: '##', format: 'h2' },
{ start: '###', format: 'h3' },
{ start: '####', format: 'h4' },
{ start: '#####', format: 'h5' },
{ start: '######', format: 'h6' },
{ start: '1. ', cmd: 'InsertOrderedList' },
{ start: '* ', cmd: 'InsertUnorderedList' },
{ start: '- ', cmd: 'InsertUnorderedList' }
], // 快速排版 类似于markdown
init_instance_callback: editor => { // 初始化结束后执行, 里面实现双向数据绑定功能
editor.on('Input undo redo Change execCommand SetContent', (e) => {
// editor.getContent({ format: ''text }) // 获取纯文本
$emit('change', editor.getContent())
})
},
setup: (editor) => { // 初始化前执行
// xxxx
}