如何用div手写一个富文本编辑器(contenteditable="true")
日常工作中,总会有需求要求我们编辑文本,但是又要在里面插入一些特殊的内容,并显示特定的颜色边框等等,这时候textarea就无法满足需求了,但是用富文本编辑器又有点多余,没那么多的东西,并且插入项的自定义很多富文本编辑器支持并不是那么美好,所以就不得不自己手写富文本框了,这种自己手写的想怎么玩怎么玩,封装成组件之后就很灵活。
具体实现是利用div上写上contenteditable="true"属性,使div可编辑,具体这个属性的作用我就不多说了,不知道的可以查一下
效果图展示
不难看出,分为了3个区域:顶部工具栏,输入主体,底部工具栏
HTML代码
xml
<el-form-item label="文本消息内容:" prop="content">
<!-- 这里是用于表单校验的,不需要的话可以删除 -->
<el-input v-model="textData!.content" style="display: none" />
<div class="text-editor">
<!-- 顶部工具栏 -->
<div class="toolbar flex align-center pd-lr-20 text-16">
<span v-if="props.toolbar.length > 0">插入:</span>
<div class="tools flex align-center mg-l-10 flex-sub text-14">
<template v-for="(item, index) in toolbarList">
<div
class="tools-item flex align-center pointer"
:key="index"
:class="item.key"
v-if="props.toolbar.indexOf(item.key) >= 0"
@click="handleToolbarClick(item.key)"
>
<el-icon v-if="item.key === 'nickname'"><User /></el-icon
><el-icon v-else><Link /></el-icon>{{ item.title }}
</div>
</template>
</div>
</div>
<!-- 编辑器主体部分 -->
<div class="main">
<div
id="message-input"
ref="messageInputDom"
class="mess-input"
contenteditable="true"
spellcheck="false"
@paste="handlePaste"
@blur="saveCursor"
@input="inputChange"
></div>
</div>
</div>
<!-- 底部插入表情 -->
<div class="emoji flex align-center" v-if="emoji">
点击插入:<emoji @insert="insertEmoji"></emoji>
</div>
</el-form-item>
TS代码
typescript
import { reactive, ref } from 'vue';
import { createUniqueString } from '@/utils/util';
import Emoji from './emoji/index.vue';
import { debounce } from 'lodash';
const props = defineProps({
toolbar: {
type: Array,
default: () => {
return ['nickname', 'link', 'miniprogram', 'variable', 'unvariable'];
}
},
emoji: {
type: Boolean,
default: true
}
});
const textData = ref({
content: '',
});
const toolbarList = ref([
{
title: '客户昵称',
template: '',
icon: '@/assets/images/editor/toolbar/ic_fsnc.png',
key:'nickname'
},
// 其余的可自行扩展
]);
/**
* 顶部工具栏按钮点击
*/
const handleToolbarClick = (bartype: string) => {
switch (bartype) {
case 'nickname':
insertNickname();
break;
// case 'link':
// openLinkModal();
// break;
// case 'unvariable':
// insertUnvariable();
// break;
default:
console.log('不晓得点的啥子');
}
};
// 插入客户昵称
const insertNickname = () => {
insertHtml('<a class="yz-tag primary" contenteditable="false">粉丝昵称</a>');
};
// 光标离开记录光标位置
const saveCursor = () => {
let selection = window.getSelection();
range.value = selection!.getRangeAt(0);
};
// 公共方法,插入节点到编辑器,当用户从未手动点击编辑器(编辑器未获取到焦点)时设置焦点到文档末尾
const insertHtml = (data: any) => {
if (range.value) {
const textNode = parseHTML(data);
range.value.insertNode(textNode);
inputChange();
setFocus();
} else {
messageInputDom.value.focus();
let selection = window.getSelection();
range.value = selection!.getRangeAt(0);
insertHtml(data);
}
};
// 将字符串转化为真实节点
const parseHTML = (htmlString: string): any => {
const range = document.createRange();
const fragment = range.createContextualFragment(htmlString);
return fragment as any;
};
// 设置光标位置到最后
const setFocus = () => {
let el = messageInputDom.value;
let range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
let selection = window.getSelection();
selection!.removeAllRanges();
selection!.addRange(range);
};
// 这里我的需求是转换成保留格式的纯文本,如果你们不需要domData就够用
const inputChange =debounce(() => {
const domData = messageInputDom.value.innerHTML;
console.log('%c [ 初始dom ]-276', 'font-size:13px; background:pink; color:#bf2c9f;', domData);
// 将HTML实体转换为普通文本,防止特殊字符转译
const plainText = decodeHtmlEntities(domData);
let nHtml = plainText
.replace(/<a [^>]+primary[^>]+>粉丝昵称</a *>/g, '%NICKNAME%')
.replace(/<a [^>]+primary[^>]+>插入时间变量</a *>/g, '%TIME%')
// 空p或div只含br的情况,转为对应数量的换行
.replace(/<(p|div)>\s*((<br\s*/?>\s*)+)</\1>/gi, (m, tag, brs) => '\n'.repeat((brs.match(/<br/gi) || []).length))
// 其他div或p,内容后加一个换行
.replace(/<(p|div)[^>]*>([\s\S]*?)</\1>/gi, (m, tag, content) => {
// 如果内容里已经有换行结尾,则不再加
content = content.replace(/<br\s*/?>/gi, '\n');
return content.endsWith('\n') ? content : content + '\n';
})
// 只去除非a标签,a标签保留
.replace(/<(?!/?a(?=>|\s))[^>]+>/gi, '')
// 去除末尾多余换行
.replace(/\n+$/, '');
textData.value.content = nHtml.trim();
console.log(
'%c [ 文本最终结果 ]-298',
'font-size:13px; background:pink; color:#bf2c9f;',
textData.value
);
},100) ;
// 解码html实体
function decodeHtmlEntities(str: any) {
const txt = document.createElement('textarea');
txt.innerHTML = str;
return txt.value;
}
// 粘贴设置防止xss攻击,我这里由于需求原因做了一下换行相关处理,保证从微信和word之类的复制过来格式不乱
const handlePaste = (e: any) => {
e.preventDefault();
const clipboardData = e.clipboardData || (window as any).clipboardData;
const html = clipboardData.getData('text/html');
let text = clipboardData.getData('text/plain') || '';
if (html) {
// 用 DOM 解析富文本,只保留文本内容和换行
const pdom = document.createElement('div');
pdom.innerHTML = html;
// 获取带换行的纯文本
text = getTextWithLineBreaks(pdom);
// 去除首尾多余换行
text = text.replace(/^\n+|\n+$/g, '');
document.execCommand('insertText', false, text);
} else {
// 纯文本直接粘贴
document.execCommand('insertText', false, text);
}
};
// 保留文本和换行的辅助函数
function getTextWithLineBreaks(node: Node): string {
let text = '';
node.childNodes.forEach((child) => {
if (child.nodeType === 3) {
// 文本节点
text += child.textContent || '';
} else if (child.nodeType === 1) {
// 元素节点
const tag = (child as HTMLElement).tagName.toLowerCase();
if (tag === 'br') {
text += '\n';
} else {
// 递归获取子内容
const childText = getTextWithLineBreaks(child);
// 判断是否块级标签
if (['p', 'div', 'li', 'tr'].includes(tag)) {
// 只包含br的情况
const onlyBr = Array.from(child.childNodes).every(
(n) => n.nodeType === 1 && (n as HTMLElement).tagName.toLowerCase() === 'br'
);
if (onlyBr && child.childNodes.length > 0) {
// 有几个br就加几个换行
text += '\n'.repeat(child.childNodes.length);
} else if (childText !== '') {
// 有内容,内容后加一个换行
text += childText + '\n';
} else {
// 空块级标签,加一个换行
text += '\n';
}
} else {
text += childText;
}
}
}
});
return text;
}
// 插入emoji
const insertEmoji = (v: any) => {
insertHtml(v.emoji);
};
const initHtml = () => {
messageInputDom.value.innerHTML = '<p><br></p>';
};
initHtml()
有个特殊的处理地点,如果按我上述代码,最后并不是直接调用initHtml,因为传给后端的是保留格式的纯文本,所以初始化的时候应该将文本转换成dom
javascript
// 转换文本中的内容为编辑器显示的内容
const transVariable = () => {
if (textData.value.content) {
const arr = textData.value.content.split('\n');
const showMsgBox = arr
.map((item) => {
return '<p>' + item + '</p>';
})
.join('')
.replace(/<a[^>]*>[^<]*</a *>/g, function (o) {
// createUniqueString我就不放了,实际是随机串生成
let id = `_${createUniqueString()}`,
linktype = '';
return o
.replace(/id="([^"]*)"/, function (t, idStr) {
linktype = idStr.split('_')[0];
if (linktype === 'link') {
id = `link_${id}`;
} else {
id = `mini_${id}`;
}
return `id="${id}" class="yz-tag has-edit ${linktype == 'link' ? 'info' : 'success'}" contenteditable="false"`;
})
.replace(/data-miniprogram-path="([^"]*)"/, function (t, pathStr) {
return `${t} href="${pathStr}"`;
})
.replace(/>([^<]*)</, function (t, text) {
return `>${text}<`;
});
})
.replace(
/%NICKNAME%/g,
'<a class="yz-tag primary" contenteditable="false">粉丝昵称</a>'
)
.replace(
/%TIME%/g,
'<a class="yz-tag primary" contenteditable="false">插入时间变量</a>'
);
messageInputDom.value.innerHTML = showMsgBox;
} else {
initHtml();
}
};
// 我这里不使用nextick 是因为弹窗弹出有动画时间,做成了组件放弹窗中了,如果是不是弹窗或有延迟什么的不必我这样
setTimeout(() => {
transVariable();
}, 100);
逻辑梳理
1、初始化页面,如果有入参则格式化(transVariable),如果没有则直接初始化(initHtml)
2、点击工具栏插入对应内容(handleToolbarClick)根据点击类型判断。代码中只有一个示例,根据实际情况添加,内容确认完成后确认插入节点到编辑器(insertHtml)。insertHtml是保证插入内容在指定位置,插入内容后光标定位至内容最后(setFocus)
3、当触发blur时记录当前鼠标位置,确保点击插入内容时在光标位置
而不是在其他位置
4、当粘贴文本或富文本内容时进行格式化处理(handlePaste)
5、当输入内容时(inputChange)将dom格式化处理转换为需要的文字赋值给textData。加上防抖防止粘贴和插入时多次触发(防抖我用的lodash,大家也可以自己写一个,也不难)
6、点击表情时直接插入内容,表情组件很多我这里就不放了,不需要的也可以直接删除
注
1、内容最好还是做成组件,毕竟单内容来说不少了。作为组件时更改
ini
const textData = defineModel({
default: () => ({
content: ''
})
});
// 使用
<TextEditor
:toolbar="toolbar"
v-model="textData"
/>
2、表单验证是否需要自行添加哈
ini
const textRef = ref()
const validateForm = () => {
if (!textRef.value) return;
return textRef.value.validate();
};
defineExpose({
transVariable,
setFocus,
validateForm
});
完整代码
我自己的toolbarList是个对象,根据实际情况自行选择
xml
<template>
<div class="text-14 color-28" :class="{ 'mg-t-16': typeValue === 'text' }">
<el-form :model="textData" ref="textRef" label-position="top" :rules="rules">
<el-form-item label="文本消息内容:" prop="content">
<el-input v-model="textData!.content" style="display: none" />
<div class="text-editor" :class="{ 'mg-t-10': typeValue === 'text' }">
<div class="textEditor"></div>
<!-- 顶部工具栏 -->
<div class="toolbar flex align-center pd-lr-20 text-16">
<span v-if="props.toolbar.length > 0">插入:</span>
<div class="tools flex align-center mg-l-10 flex-sub text-14">
<template v-for="(item, name) in toolbarList">
<div
class="tools-item flex align-center pointer"
:key="name"
:class="name"
v-if="props.toolbar.indexOf(name) >= 0"
@click="handleToolbarClick(name)"
>
<el-icon v-if="name === 'nickname'"><User /></el-icon
><el-icon v-else><Link /></el-icon>{{ item.title }}
</div>
</template>
</div>
</div>
<!-- 编辑器主体部分 -->
<div class="main">
<div
id="message-input"
ref="messageInputDom"
class="mess-input"
contenteditable="true"
spellcheck="false"
@paste="handlePaste"
@blur="saveCursor"
@input="inputChange"
></div>
</div>
</div>
<!-- 底部插入表情 -->
<div class="emoji flex align-center" v-if="emoji">
点击插入:<emoji @insert="insertEmoji"></emoji>
</div>
</el-form-item>
</el-form>
<MainDialog v-model="dialogFormVisible" title="插入链接" align-center width="720">
<div class="modalForm linkModal">
<div class="modalForm-item">
<div class="modalForm-label">链接文本:</div>
<div class="modalForm-content">
<el-input
class="modalForm-input"
type="text"
placeholder="请输入链接显示文本"
v-model.trim="linkForm.name"
/>
</div>
</div>
<div class="modalForm-item mg-t-20">
<div class="modalForm-label">链接地址:</div>
<div class="modalForm-content">
<el-input
class="modalForm-input"
type="text"
placeholder="请输入要插入的链接地址"
v-model.trim="linkForm.link"
/>
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button type="info" class="cancel margin-right-sm" @click="handleClose">
取消
</el-button>
<el-button type="primary" class="confirm" @click="handleConfirm">
确定
</el-button>
</div>
</template>
</MainDialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import MainDialog from '../../MainDialog/index.vue';
import { ElMessage, type FormRules } from 'element-plus';
import { urlReg } from '@/constant/reg';
import { createUniqueString } from '@/utils/util';
import Emoji from './emoji/index.vue';
import { debounce } from 'lodash';
const props = defineProps({
modelValue: {
type: Object,
default: () => {
return {
content: ''
};
}
},
toolbar: {
type: Array,
default: () => {
return ['nickname', 'link', 'miniprogram', 'variable', 'unvariable'];
}
},
typeValue: {
type: String,
default: 'text'
},
toolbarList: {
type: Object,
default: () => {
return {
nickname: {
title: '客户昵称',
template: '',
icon: '@/assets/images/editor/toolbar/ic_fsnc.png'
}
// link: {
// title: '超链接',
// template: '',
// icon: '@/assets/images/editor/toolbar/ic_clj.png'
// }
};
}
},
emoji: {
type: Boolean,
default: true
}
});
const rules = reactive<FormRules>({
content: [{ required: true, message: '请输入内容', trigger: ['blur', 'change'] }]
});
const messageInputDom = ref();
const range = ref();
const textRef = ref();
const dialogFormVisible = ref(false);
const linkForm = ref({
name: '',
link: ''
});
const textData = defineModel({
default: () => ({
content: ''
})
});
/**
* 顶部工具栏按钮点击
*/
const handleToolbarClick = (bartype: string) => {
switch (bartype) {
case 'nickname':
insertNickname();
break;
case 'link':
openLinkModal();
break;
case 'unvariable':
insertUnvariable();
break;
default:
console.log('不晓得点的啥子');
}
};
// 记录光标位置
const saveCursor = () => {
let selection = window.getSelection();
range.value = selection!.getRangeAt(0);
};
// 插入客户昵称
const insertNickname = () => {
insertHtml('<a class="yz-tag primary" contenteditable="false">粉丝昵称</a>');
};
// 插入时间变量
const insertUnvariable = () => {
insertHtml('<a class="yz-tag primary" contenteditable="false">插入时间变量</a>');
};
// 打开链接弹窗
const openLinkModal = () => {
dialogFormVisible.value = true;
};
// 关闭链接弹窗
const handleClose = () => {
dialogFormVisible.value = false;
};
// 链接确认
const handleConfirm = () => {
const errmsg = verifyLinkForm();
if (errmsg) {
return ElMessage(errmsg);
}
insertLink();
handleClose();
};
const verifyLinkForm = () => {
if (!linkForm.value.name) {
return '请输入链接显示文本';
}
if (!linkForm.value.link) {
return '请输入要插入的链接地址';
}
if (!urlReg.test(linkForm.value.link)) {
return '请输入正确格式的链接地址!';
}
return;
};
// 新插入超链接
const insertLink = () => {
const id = 'link_' + createUniqueString();
insertHtml(
'<a id="'
.concat(id, '" href="')
.concat(
linkForm.value.link.includes('http') || linkForm.value.link.includes('weixin://')
? linkForm.value.link
: 'http://' + linkForm.value.link,
'" data-name="'
)
.concat(
linkForm.value.name,
'" class="yz-tag info has-edit" contenteditable="false" onclick="return false;">'
)
.concat(linkForm.value.name, '</a>')
);
};
// 公共方法,插入节点到编辑器,当用户从未手动点击编辑器(编辑器未获取到焦点)时设置焦点到文档末尾
const insertHtml = (data: any) => {
if (range.value) {
const textNode = parseHTML(data);
range.value.insertNode(textNode);
inputChange();
setFocus();
} else {
messageInputDom.value.focus();
let selection = window.getSelection();
range.value = selection!.getRangeAt(0);
insertHtml(data);
}
};
const initHtml = () => {
messageInputDom.value.innerHTML = '<p><br></p>';
};
// 设置光标位置到最后
const setFocus = () => {
let el = messageInputDom.value;
let range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
let selection = window.getSelection();
selection!.removeAllRanges();
selection!.addRange(range);
};
// 将字符串转化为真实节点
const parseHTML = (htmlString: string): any => {
const range = document.createRange();
const fragment = range.createContextualFragment(htmlString);
return fragment as any;
};
// 插入emoji
const insertEmoji = (v: any) => {
insertHtml(v.emoji);
};
// 解码html实体
function decodeHtmlEntities(str: any) {
const txt = document.createElement('textarea');
txt.innerHTML = str;
return txt.value;
}
const inputChange =debounce(() => {
const domData = messageInputDom.value.innerHTML;
// 将HTML实体转换为普通文本,防止特殊字符转译
const plainText = decodeHtmlEntities(domData);
let nHtml = plainText
.replace(/<a [^>]+primary[^>]+>粉丝昵称</a *>/g, '%NICKNAME%')
.replace(/<a [^>]+primary[^>]+>插入时间变量</a *>/g, '%TIME%')
// 空p或div只含br的情况,转为对应数量的换行
.replace(/<(p|div)>\s*((<br\s*/?>\s*)+)</\1>/gi, (m, tag, brs) => '\n'.repeat((brs.match(/<br/gi) || []).length))
// 其他div或p,内容后加一个换行
.replace(/<(p|div)[^>]*>([\s\S]*?)</\1>/gi, (m, tag, content) => {
// 如果内容里已经有换行结尾,则不再加
content = content.replace(/<br\s*/?>/gi, '\n');
return content.endsWith('\n') ? content : content + '\n';
})
// 只去除非a标签,a标签保留
.replace(/<(?!/?a(?=>|\s))[^>]+>/gi, '')
// 去除末尾多余换行
.replace(/\n+$/, '');
textData.value.content = nHtml.trim();
},100) ;
// 转换文本中的内容为编辑器显示的内容
const transVariable = () => {
if (textData.value.content) {
const arr = textData.value.content.split('\n');
const showMsgBox = arr
.map((item) => {
return '<p>' + item + '</p>';
})
.join('')
.replace(/<a[^>]*>[^<]*</a *>/g, function (o) {
let id = `_${createUniqueString()}`,
linktype = '';
return o
.replace(/id="([^"]*)"/, function (t, idStr) {
linktype = idStr.split('_')[0];
if (linktype === 'link') {
id = `link_${id}`;
} else {
id = `mini_${id}`;
}
return `id="${id}" class="yz-tag has-edit ${linktype == 'link' ? 'info' : 'success'}" contenteditable="false"`;
})
.replace(/data-miniprogram-path="([^"]*)"/, function (t, pathStr) {
return `${t} href="${pathStr}"`;
})
.replace(/>([^<]*)</, function (t, text) {
return `>${text}<`;
});
})
.replace(
/%NICKNAME%/g,
'<a class="yz-tag primary" contenteditable="false">粉丝昵称</a>'
)
.replace(
/%TIME%/g,
'<a class="yz-tag primary" contenteditable="false">插入时间变量</a>'
);
messageInputDom.value.innerHTML = showMsgBox;
} else {
initHtml();
}
};
// 不能使用nextick 因为弹窗弹出有动画时间
setTimeout(() => {
transVariable();
}, 100);
// 粘贴设置防止xss攻击
const handlePaste = (e: any) => {
e.preventDefault();
const clipboardData = e.clipboardData || (window as any).clipboardData;
const html = clipboardData.getData('text/html');
let text = clipboardData.getData('text/plain') || '';
if (html) {
// 用 DOM 解析富文本,只保留文本内容和换行
const pdom = document.createElement('div');
pdom.innerHTML = html;
// 获取带换行的纯文本
text = getTextWithLineBreaks(pdom);
// 去除首尾多余换行
text = text.replace(/^\n+|\n+$/g, '');
document.execCommand('insertText', false, text);
} else {
// 纯文本直接粘贴
document.execCommand('insertText', false, text);
}
};
// 保留文本和换行的辅助函数
function getTextWithLineBreaks(node: Node): string {
let text = '';
node.childNodes.forEach((child) => {
if (child.nodeType === 3) {
// 文本节点
text += child.textContent || '';
} else if (child.nodeType === 1) {
// 元素节点
const tag = (child as HTMLElement).tagName.toLowerCase();
if (tag === 'br') {
text += '\n';
} else {
// 递归获取子内容
const childText = getTextWithLineBreaks(child);
// 判断是否块级标签
if (['p', 'div', 'li', 'tr'].includes(tag)) {
// 只包含br的情况
const onlyBr = Array.from(child.childNodes).every(
(n) => n.nodeType === 1 && (n as HTMLElement).tagName.toLowerCase() === 'br'
);
if (onlyBr && child.childNodes.length > 0) {
// 有几个br就加几个换行
text += '\n'.repeat(child.childNodes.length);
} else if (childText !== '') {
// 有内容,内容后加一个换行
text += childText + '\n';
} else {
// 空块级标签,加一个换行
text += '\n';
}
} else {
text += childText;
}
}
}
});
return text;
}
const validateForm = () => {
if (!textRef.value) return;
return textRef.value.validate();
};
defineExpose({
transVariable,
setFocus,
validateForm
});
</script>
<style lang="scss" scoped>
.toolbar {
border: 1px solid #e5e5e5;
background-color: #f8f8f8;
height: 46px;
border-bottom: none;
.tools {
height: 100%;
&-item {
height: 100%;
&:not(:first-child) {
margin-left: 20px;
}
&.nickname {
color: #fd5451;
}
&.link {
color: #67c23a;
}
&.miniprogram {
color: #4e73ec;
}
&.variable {
color: #686868;
}
}
}
}
.main {
border: 1px solid #e5e5e5;
border-top: none;
}
.mess-input {
padding: 8px;
height: 255px;
overflow: auto;
word-break: break-all; // 允许长单词或符号在任意位置换行
overflow-wrap: break-word; // 确保内容不会超出容器
line-break: anywhere; // 允许在任何地方断行,包括全角符号
}
.modalForm {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 396px;
&.linkModal {
padding: 0 56px;
}
&.miniModal {
padding: 0 24px;
}
font-size: 16px;
color: #282828;
&-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
&-label {
font-weight: bold;
width: 0;
flex: 1;
text-align: right;
white-space: nowrap;
word-break: keep-all;
}
&-content {
margin-left: 20px;
width: 460px;
:deep(.el-select) {
height: 48px;
.el-input {
&__inner {
height: 48px;
line-height: 48px;
}
&__icon {
line-height: 48px;
}
}
}
}
&-input {
width: 460px;
height: 48px;
box-sizing: border-box;
outline: none;
&::-webkit-input-placeholder {
color: #999999;
}
}
&-link {
font-size: 14px;
}
}
.emoji {
font-size: 14px;
margin-top: 16px;
}
.text-editor {
width: 100%;
}
</style>