当前位置: 首页 > news >正文

如何用div手写一个富文本编辑器(contenteditable=“true“)

如何用div手写一个富文本编辑器(contenteditable=“true”)

日常工作中,总会有需求要求我们编辑文本,但是又要在里面插入一些特殊的内容,并显示特定的颜色边框等等,这时候textarea就无法满足需求了,但是用富文本编辑器又有点多余,没那么多的东西,并且插入项的自定义很多富文本编辑器支持并不是那么美好,所以就不得不自己手写富文本框了,这种自己手写的想怎么玩怎么玩,封装成组件之后就很灵活。

具体实现是利用div上写上contenteditable="true"属性,使div可编辑,具体这个属性的作用我就不多说了,不知道的可以查一下

效果图展示

在这里插入图片描述

不难看出,分为了3个区域:顶部工具栏,输入主体,底部工具栏

HTML代码

<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"><divclass="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"><divid="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代码

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

// 转换文本中的内容为编辑器显示的内容
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、内容最好还是做成组件,毕竟单内容来说不少了。作为组件时更改

const textData = defineModel({default: () => ({content: ''})
});// 使用
<TextEditor:toolbar="toolbar"v-model="textData"
/>

2、表单验证是否需要自行添加哈

const textRef = ref()
const validateForm = () => {if (!textRef.value) return;return textRef.value.validate();
};
defineExpose({transVariable,setFocus,validateForm
});

完整代码

​ 我自己的toolbarList是个对象,根据实际情况自行选择

<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"><divclass="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"><divid="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-inputclass="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-inputclass="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>

相关文章:

  • AT_abc410_f [ABC410F] Balanced Rectangles 题解
  • 远程桌面连接 - 允许电脑从网络外部访问计算机
  • 视频设备:直联正常,通过卫星无画面,因为延迟太大
  • Flutter动画全解析:从AnimatedContainer到AnimationController的完整指南
  • 从源码出发:全面理解 Kafka Connect Jdbc与Kafka Connect 机制
  • 基于RISC-V架构的服务器OS构建DevOps体系的全方位方案
  • 神经网络课设
  • 关于 常见 JavaScript 混淆类型
  • 八股---9.消息中间件
  • Redis中的分布式锁之SETNX底层实现
  • 资深Java工程师的面试题目(一)并发编程
  • Agent开发相关工具
  • 迭代器模式:集合遍历的统一之道
  • 【web应用】在 Vue 3 中实现饼图:使用 Chart.js实现饼图显示数据分析结果
  • wpf 队列(Queue)在视觉树迭代查找中的作用分析
  • 行列式展开定理(第三种定义) 线性代数
  • 系统思考:渐糟之前先变好
  • 笑傲江湖版大模型:武侠智能体的构建与江湖法则
  • Java日志使用
  • VASP 教程:VASP 机器学习力场计算硅的声子谱
  • 百度抓取网站频率/618网络营销策划方案
  • 域名到期对网站的影响/国外seo工具
  • 济南开发网站/长春seo外包
  • 企顺网网站建设/网站建设公司地址在哪
  • wordpress网站加载慢/淘宝排名查询
  • 湘潭做网站 磐石网络/网络营销软件大全