大半年前项目里遇到的需求,一直没整理,虽然不咋难,当时写的时候还是废了一些时间找思路,记录一下
需求点
- 复制粘贴、上传图片不走后端接口,转base64,并且压缩图片大小
- 自定义下拉框类似于word中的标题级别(如下图),选中后须对应每一项的字体、字号、行高以及段落对齐方式
完成效果如图
3.点击按钮在光标所在位置插入文字
思路
- 富文本编辑器配置中uploadImage方法内图片上传、复制后的自定义回显方式
- 注册新菜单,点击菜单时触发的函数exec中设置编辑器字体、字体大小、行高、对齐方式等,也有一些小细节后续会标出
- 插入文字本质就是对编辑器的节点进行操作,参照官网节点操作
首先安装依赖
- wangeditor/editor:5.1.23
- wangeditor/editor-for-vue": 1.0.2 直接上代码
<template>
<div>
<div class="w-e-for-vue">
<!-- 工具栏 -->
<Toolbar class="w-e-for-vue-toolbar" :editor="editor" :defaultConfig="toolbarConfig">
<div class="btn-op">
<Button type="text" icon="md-add" @click="fd" style="cursor: pointer; margin-right: 5px"></Button>
<Button type="text" icon="md-remove" @click="sx" style="cursor: pointer"></Button></div
></Toolbar>
<!-- 编辑器 -->
<Editor
class="w-e-for-vue-editor"
style="height: 597px; overflow-y: hidden"
:disabled="isReadonly"
:defaultConfig="editorConfig"
v-model="content"
@onChange="onChange"
@onCreated="onCreated"
@customPaste="customPaste"
ref="editorWang"
id="editorWang"
>
</Editor>
</div>
</div>
</template>
<script>
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { Boot, SlateTransforms, SlateEditor, SlateElement } from '@wangeditor/editor'
class MySelectMenu {
constructor() {
this.title = 'My Select Menu'
this.tag = 'select'
this.width = 65
}
// 下拉框的选项
getOptions(editor) {
return that.optionList
}
// 菜单是否需要激活(如选中加粗文本,“加粗”菜单会激活),用不到则返回 false
isActive(editor) {
return false
}
// 获取菜单执行时的 value ,用不到则返回空 字符串或 false
getValue(editor) {
return that.selectFm // 匹配 options 其中一个 value
}
// 菜单是否需要禁用(如选中 H1 ,“引用”菜单被禁用),用不到则返回 false
isDisabled(editor) {
return false
}
// 点击菜单时触发的函数
exec(editor, value) {
that.selectFm = value
that.optionList.map(item => {
if (item.value == value) {
item.selected = true
} else {
item.selected = false
}
})
let nodeEntries = SlateEditor.nodes(editor, {
match: node => {
if (SlateElement.isElement(node)) {
return true
}
return false
},
universal: true,
})
console.log(nodeEntries, 'nodeEntries')
let targetNodes = []
if (nodeEntries == null) {
console.log('当前未选中的 paragraph')
} else {
for (let nodeEntry of nodeEntries) {
let [node] = nodeEntry
let newNode = JSON.parse(JSON.stringify([node]))
console.log(newNode, 'newNode')
newNode.map(nodes => {
targetNodes.push({ type: 'paragraph', children: nodes.children })
})
}
}
SlateTransforms.removeNodes(editor) // 删除第一个空行(重要)
targetNodes.map(child => {
child.children.map(t => {
if (value == '标题') {
child.lineHeight = '36px'
child.textIndet = '24px'
child.textAlign = 'center'
that.$set(t, 'fontFamily', '方正小标宋_GBK')
that.$set(t, 'fontSize', '29px')
} else if (value == '一级标题') {
child.lineHeight = '36px'
child.textIndet = '24px'
child.textAlign = 'justify'
that.$set(t, 'fontFamily', '方正黑体_GBK')
that.$set(t, 'fontSize', '21px')
} else if (value == '二级标题') {
child.lineHeight = '36px'
child.textIndet = '24px'
child.textAlign = 'justify'
that.$set(t, 'fontFamily', '方正楷体_GBK')
that.$set(t, 'fontSize', '21px')
} else if (value == '三级标题' || value == '四级标题' || value == '正文') {
child.lineHeight = '36px'
child.textIndet = '24px'
child.textAlign = 'justify'
that.$set(t, 'fontFamily', '方正仿宋_GBK')
that.$set(t, 'fontSize', '21px')
}
})
})
//向编辑器插入设置过样式的节点
SlateTransforms.insertNodes(editor, targetNodes, { mode: 'highest' })
}
}
import { replaceImagesFileSourceWithInlineRepresentation, extractImageDataFromRtf, findAllImgSrcsFromHtml } from './tool.js'
//引入字体
import '@/assets/fonts/font.css'
let that
export default {
name: 'wangEditor',
components: { Editor, Toolbar },
props: {
value: {
type: String,
default: '',
},
// 富文本是否禁用
isReadonly: {
type: Boolean,
default: false,
},
// 富文本是否禁用
inModalShowStatus: {
type: Boolean,
default: true,
},
// 追加的内容
insetVal: {
type: String,
default: '',
},
},
data() {
return {
selectFm: '正文',
optionList: [
{ value: '标题', text: '标题', selected: false, styleForRenderMenuList: { 'font-size': '28px', 'font-weight': 'bold', 'font-family': '方正小标宋_GBK' } },
{ value: '一级标题', text: '一级标题', selected: false, styleForRenderMenuList: { 'font-size': '24px', 'font-family': '方正黑体_GBK' } },
{ value: '二级标题', text: '二级标题', selected: false, styleForRenderMenuList: { 'font-size': '20px', 'font-family': '方正楷体_GBK' } },
{ value: '三级标题', text: '三级标题', selected: false, styleForRenderMenuList: { 'font-size': '16px', 'font-family': '方正仿宋_GBK' } },
{ value: '四级标题', text: '四级标题', selected: false, styleForRenderMenuList: { 'font-size': '16px', 'font-family': '方正仿宋_GBK' } },
{ value: '正文', text: '正文', selected: true, styleForRenderMenuList: { 'font-size': '16px', 'font-family': '方正仿宋_GBK' } },
],
modal: false,
scale: 1.2,
editor: null, // 富文本编辑器对象
content: null, // 富文本内容
placeholder: null, // 富文本占位内容
uploadImageUrl: null, // 富文本上传图片的地址
// 工具栏配置
toolbarConfig: {
toolbarKeys: [
'selectFM',
'fontSize',
'fontFamily',
'lineHeight',
'|',
'blockquote',
'bold',
'underline',
'italic',
'|',
'insertTable',
{
key: 'group-image',
title: '图片',
iconSvg:
'<svg viewBox="0 0 1024 1024"><path d="M959.877 128l0.123 0.123v767.775l-0.123 0.122H64.102l-0.122-0.122V128.123l0.122-0.123h895.775zM960 64H64C28.795 64 0 92.795 0 128v768c0 35.205 28.795 64 64 64h896c35.205 0 64-28.795 64-64V128c0-35.205-28.795-64-64-64zM832 288.01c0 53.023-42.988 96.01-96.01 96.01s-96.01-42.987-96.01-96.01S682.967 192 735.99 192 832 234.988 832 288.01zM896 832H128V704l224.01-384 256 320h64l224.01-192z"></path></svg>',
menuKeys: ['insertImage', 'uploadImage'],
},
{
key: 'group-indent',
title: '缩进',
iconSvg: '<svg viewBox="0 0 1024 1024"><path d="M0 64h1024v128H0z m384 192h640v128H384z m0 192h640v128H384z m0 192h640v128H384zM0 832h1024v128H0z m0-128V320l256 192z"></path></svg>',
menuKeys: ['indent', 'delIndent'],
},
{
key: 'group-justify',
title: '对齐',
iconSvg:
'<svg viewBox="0 0 1024 1024"><path d="M768 793.6v102.4H51.2v-102.4h716.8z m204.8-230.4v102.4H51.2v-102.4h921.6z m-204.8-230.4v102.4H51.2v-102.4h716.8zM972.8 102.4v102.4H51.2V102.4h921.6z"></path></svg>',
menuKeys: ['justifyLeft', 'justifyRight', 'justifyCenter', 'justifyJustify'],
},
{
key: 'group-more-style',
title: '更多',
iconSvg:
'<svg viewBox="0 0 1024 1024"><path d="M204.8 505.6m-76.8 0a76.8 76.8 0 1 0 153.6 0 76.8 76.8 0 1 0-153.6 0Z"></path><path d="M505.6 505.6m-76.8 0a76.8 76.8 0 1 0 153.6 0 76.8 76.8 0 1 0-153.6 0Z"></path><path d="M806.4 505.6m-76.8 0a76.8 76.8 0 1 0 153.6 0 76.8 76.8 0 1 0-153.6 0Z"></path></svg>',
menuKeys: ['through', 'code', 'sup', 'sub', 'clearStyle'],
},
'|',
'color',
'bgColor',
'bulletedList',
'numberedList',
'todo',
'emotion',
'insertLink',
'codeBlock',
'divider',
'undo',
'redo',
'|',
'fullScreen',
], // 显示指定的菜单项
excludeKeys: ['group-video', 'insertVideo', 'uploadVideo'], // 隐藏指定的菜单项
insertKeys: {
keys: [ 'selectFM'], //自定义tab按钮
},
},
// 编辑器配置
editorConfig: {
withCredentials: true, //定义该属性为ture表示允许跨域访问
autoFocus: true,
placeholder: '',
uploadVideoShow: false,
fontFamily: '方正仿宋_GBK',
MENU_CONF: {
// 图片上传
uploadImage: {
// 自定义上传图片
async customUpload(file, insertFn) {
console.log(file)
const isPic = file.type.split('/')[0] === 'image'
const isLt2M = file.size / 1024 / 1024 <= 10
if (!isPic) {
that.$Message.error('只能上传图片噢~')
} else if (!isLt2M) {
that.$Message.error('上传图片大小不能超过 10MB!')
} else {
let filePath = that.imageToBase64(file) // 图片转base64 不走上传
console.log(filePath)
insertFn(filePath, '', filePath) // 将图片插入编辑器显示
}
},
},
fontFamily: {
width: 133,
fontFamilyList: [
'黑体',
'仿宋',
'方正黑体_GBK',
'方正小标宋_GBK',
'方正仿宋_GBK',
'方正楷体_GBK',
'楷体',
'标楷体',
'华文仿宋',
'华文楷体',
'宋体',
'微软雅黑',
'Arial',
'Tahoma',
'Verdana',
'Times New Roman',
'Courier New',
],
},
lineHeight: {
lineHeightList: ['10px', '15px', '20px', '25px', '28px', '30px', '33px', '36px', '40px', '46px', '52px', '58px', '64px', '72px'],
},
fontSize: {
fontSizeList: ['12px', '13px', '14px', '15px', '16px', '18px', '20px', '21px', '22px', '24px', '29px', '30px', '32px', '34px', '48px', '56px'],
},
},
pasteTextHandle: function (pasteStr) {
var newStr = pasteStr.replace(/@font-face{[^>]*div.Section0{page:Section0;}/g, '')
return newStr
},
},
}
},
watch: {
value: {
handler: function (newVal) {
if (newVal != null && newVal != '') {
if (that.editor) {
that.$nextTick(() => {
that.content = newVal
})
}
}
},
immediate: true,
},
insetVal: {
handler(newVal, oldVal) {
if (newVal != null) {
console.log(newVal)
this.insertHTML(newVal)
}
},
immediate: true,
},
inModalShowStatus: {
handler: function (newVal, oldVal) {
if (!newVal) {
if (this.editor != null) {
this.editor.destroy()
}
} else {
this.onCreated()
}
},
immediate: true,
},
},
created() {
let that = this
// const menu1Conf = {
// key: 'zoomD', // 定义 menu key :要保证唯一、不重复(重要)
// factory() {
// return new MyButtonMenu(that)
// },
// }
const menu2Conf = {
key: 'selectFM', // 定义 menu key :要保证唯一、不重复(重要)
factory() {
return new MySelectMenu(that)
},
}
const module = {
menus: [menu2Conf],
}
Boot.registerModule(module)
},
mounted() {
that = this
this.setSpellCheckFasle() // 设置拼写检查 spellcheck 为假
document.activeElement.blur() // 取消富文本自动聚焦且禁止虚拟键盘弹出
// 异步渲染编辑器
this.$nextTick(() => {
this.content = this.value
})
},
/**
* 销毁富文本编辑器
*/
beforeDestroy() {
if (this.editor != null) {
this.editor.destroy()
}
},
methods: {
imageToBase64(file) {
return new Promise((resolve, reject) => {
var reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = () => {
console.log('file 转 base64结果:' + reader.result)
that.compress(reader.result, 1.5, function (base64) {
console.log(base64)
resolve(base64)
})
// resolve(reader.result)
}
reader.onerror = function (error) {
reject(error)
console.log('Error: ', error)
}
})
},
/* 压缩base64图片*/
compress(
base64, // 源图片
rate, // 缩放比例
callback, // 回调
) {
//处理缩放,转格式
var _img = new Image()
_img.src = base64
_img.onload = function () {
var _canvas = document.createElement('canvas')
var w = this.width / rate
var h = this.height / rate
_canvas.setAttribute('width', w)
_canvas.setAttribute('height', h)
_canvas.getContext('2d').drawImage(this, 0, 0, w, h)
var base64 = _canvas.toDataURL('image/jpeg')
_canvas.toBlob(function (blob) {
if (blob.size > 750 * 1334) {
//如果还大,继续压缩
that.compress(base64, rate, callback)
} else {
callback(base64)
}
}, 'image/jpeg')
}
},
replaceAllImg(html) {
// 编写逻辑来匹配 img 标签并替换 src
// 这里使用正则表达式来匹配 img 标签的 src 属性并替换为 data-src
if (html !== '' && html !== null && html !== undefined) {
const regex = /<img([^>]*)\ssrc=\s*['"]([^'"]+)['"]([^>]*)>/gi
const replacedHtml = html.replace(regex, '<img$1 data-src="$2"$3>')
return replacedHtml
}
},
//插入常用语
insertHTML(value) {
if (this.editor) {
const node = { type: 'paragraph', children: [{ text: value }] }
this.editor.insertNode(node)
}
},
/**
* 实例化富文本编辑器
* 文档链接:https://www.wangeditor.com/
*/
onCreated(editor) {
this.editor = Object.seal(editor)
this.setIsDisabled()
},
/**
* 监听富文本编辑器
*/
onChange(editor) {
console.log('onChange:', editor)
// console.log(editor.getSelectionText())
this.$emit('onChange', this.editor.getHtml())
},
/**
* this.editor.getConfig().spellcheck = false
* 由于在配置中关闭拼写检查,值虽然设置成功,但是依然显示红色波浪线
* 因此在富文本编辑器组件挂载完成后,操作 Dom 元素设置拼写检查 spellcheck 为假
*/
async setSpellCheckFasle() {
let doms = await document.getElementsByClassName('w-e-scroll')
for (let vo of doms) {
if (vo) {
if (vo.children.length > 0) {
vo.children[0].setAttribute('spellcheck', 'false')
}
}
}
},
/**
* 设置富文本是否禁用
*/
async setIsDisabled() {
if (this.editor) {
this.isReadonly ? this.editor.disable() : this.editor.enable()
}
},
/**
* 获取编辑器文本内容
*/
getEditorText() {
const editor = this.editor
if (editor != null) {
return editor.getText()
}
},
/**
* 获取编辑器Html内容
*/
getEditorHtml() {
const editor = this.editor
if (editor != null) {
return editor.getHtml()
}
},
// 自定义粘贴
customPaste(editor, event) {
// 获取粘贴的html部分(??没错粘贴word时候,一部分内容就是html),该部分包含了图片img标签
let html = event.clipboardData.getData('text/html')
// 获取rtf数据(从word、wps复制粘贴时有),复制粘贴过程中图片的数据就保存在rtf中
const rtf = event.clipboardData.getData('text/rtf')
if (html && rtf) {
console.log(rtf)
// 该条件分支即表示要自定义word粘贴
// 列表缩进会超出边框,直接过滤掉
html = html.replace(/text\-indent:\-(.*?)pt/gi, '')
// 从html内容中查找粘贴内容中是否有图片元素,并返回img标签的属性src值的集合
const imgSrcs = findAllImgSrcsFromHtml(html)
// 如果有
if (imgSrcs && Array.isArray(imgSrcs) && imgSrcs.length) {
// 从rtf内容中查找图片数据
const rtfImageData = extractImageDataFromRtf(rtf)
// 如果找到
if (rtfImageData.length) {
console.log(imgSrcs, rtfImageData, '---000')
// TODO:此处可以将图片上传到自己的服务器上
// 执行替换:将html内容中的img标签的src替换成ref中的图片数据,如果上面上传了则为图片路径
html = replaceImagesFileSourceWithInlineRepresentation(html, imgSrcs, rtfImageData)
console.log(html)
// editor.dangerouslyInsertHtml(html)
}
}
// const newHtml = html.replace(/<img[^>]+src="file:([^"]+)"/g, '')
//当复制了图片和一些图像文件时 会导致正常的img图片路径是file://本地图片并且不展示,所以将该种图片替换掉
const newHtml = this.removeFileProtocolFromImgSrc(html)
editor.dangerouslyInsertHtml(newHtml)
// 阻止默认的粘贴行为
event.preventDefault()
return false
} else {
return true
}
},
removeFileProtocolFromImgSrc(htmlContent) {
console.log(htmlContent)
// 创建DOM解析器
const parser = new DOMParser()
// 解析HTML内容为DOM文档
const doc = parser.parseFromString(htmlContent, 'text/html')
// 查找所有img标签并移除src属性以"file:"开头的img标签
const imgTags = doc.getElementsByTagName('img')
for (let i = 0; i < imgTags.length; i++) {
const imgSrc = imgTags[i].getAttribute('src')
if (imgSrc && imgSrc.startsWith('file:')) {
imgTags[i].removeAttribute('src')
}
}
// 将修改后的DOM文档转换为HTML内容
const updatedHtmlContent = new XMLSerializer().serializeToString(doc)
return updatedHtmlContent
},
},
}
</script>
<style src="@wangeditor/editor/dist/css/style.css"></style>
<style scoped>
/* table 样式 */
table {
border-top: 1px solid #ccc;
border-left: 1px solid #ccc;
}
table td,
table th {
border-bottom: 1px solid #ccc;
border-right: 1px solid #ccc;
padding: 3px 5px;
}
table th {
border-bottom: 2px solid #ccc;
text-align: center;
}
/* blockquote 样式 */
blockquote {
display: block;
border-left: 8px solid #d0e5f2;
padding: 5px 10px;
margin: 10px 0;
line-height: 1.4;
font-size: 100%;
background-color: #f1f1f1;
}
/* code 样式 */
code {
display: inline-block;
*display: inline;
*zoom: 1;
background-color: #f1f1f1;
border-radius: 3px;
padding: 3px 5px;
margin: 0 3px;
}
pre code {
display: block;
}
/* ul ol 样式 */
ul,
ol {
margin: 10px 0 10px 20px;
}
</style>
<style lang="less" scoped>
.preview-html {
width: 793px;
height: 650px;
overflow-y: scroll;
padding: 10px 40px;
background-color: #ffffff;
color: #000000;
font-family: 宋体;
font-size: 14px;
line-height: 1.3;
}
/deep/ .select-button {
width: 130px !important;
}
/deep/ .w-e-text-container {
background: rgba(230, 230, 230, 1);
}
/deep/ .w-e-scroll {
overflow-y: auto;
width: 793px;
margin: 0 auto;
background: #fff;
padding: 10px 40px;
}
.w-e-full-screen-container {
z-index: 99;
}
.w-e-for-vue {
margin: 0;
border: 1px solid #ccc;
z-index: 999;
.w-e-for-vue-toolbar {
border-bottom: 1px solid #ccc;
position: relative;
.btn-op {
position: absolute;
top: 41px;
left: 126px;
}
}
.w-e-for-vue-editor {
height: auto;
/deep/ .w-e-text-container {
.w-e-text-placeholder {
top: 6px;
color: #666;
}
pre {
code {
text-shadow: unset;
}
}
p {
margin: 5px 0;
font-size: 14px; // 设置编辑器的默认字体大小为14px
}
}
}
}
</style>
图片处理的js代码
/**
* 从html代码中匹配返回图片标签img的属性src的值的集合
* @param htmlData
* @return Array
*/
export function findAllImgSrcsFromHtml(htmlData) {
let imgReg = /<img.*?(?:>|\/>)/gi //匹配图片中的img标签
let srcReg = /src=[\'\"]?([^\'\"]*)[\'\"]?/i // 匹配图片中的src
let arr = htmlData.match(imgReg) //筛选出所有的img
if (!arr || (Array.isArray(arr) && !arr.length)) {
return false
}
let srcArr = []
for (let i = 0; i < arr.length; i++) {
let src = arr[i].match(srcReg)
// 获取图片地址
srcArr.push(src[1])
}
return srcArr
}
/**
* 从rtf内容中匹配返回图片数据的集合
* @param rtfData
* @return Array
*/
export function extractImageDataFromRtf(rtfData) {
if (!rtfData) {
return []
}
const regexPictureHeader = /{\\pict[\s\S]+?({\\\*\\blipuid\s?[\da-fA-F]+)[\s}]*/
const regexPicture = new RegExp('(?:(' + regexPictureHeader.source + '))([\\da-fA-F\\s]+)\\}', 'g')
const images = rtfData.match(regexPicture)
const result = []
if (images) {
for (const image of images) {
let imageType = false
if (image.includes('\\pngblip')) {
imageType = 'image/png'
} else if (image.includes('\\jpegblip')) {
imageType = 'image/jpeg'
}
if (imageType) {
result.push({
hex: image.replace(regexPictureHeader, '').replace(/[^\da-fA-F]/g, ''),
type: imageType,
})
}
}
}
return result
}
/**
* 将html内容中img标签的属性值替换
* @param htmlData html内容
* @param imageSrcs html中img的属性src的值的集合
* @param imagesHexSources rtf中图片数据的集合,与html内容中的img标签对应
* @param isBase64Data 是否是Base64的图片数据
* @return String
*/
export function replaceImagesFileSourceWithInlineRepresentation(htmlData, imageSrcs, imagesHexSources, isBase64Data = true) {
if (imageSrcs.length === imagesHexSources.length) {
for (let i = 0; i < imageSrcs.length; i++) {
const newSrc = isBase64Data ? `data:${imagesHexSources[i].type};base64,${_convertHexToBase64(imagesHexSources[i].hex)}` : imagesHexSources[i]
htmlData = htmlData.replace(imageSrcs[i], newSrc)
}
}
return htmlData
}
/**
* 十六进制转base64
*/
export function _convertHexToBase64(hexString) {
return btoa(
hexString
.match(/\w{2}/g)
.map(char => {
return String.fromCharCode(parseInt(char, 16))
})
.join(''),
)
}