近期因为产品迭代,须要新增一个评论性能,且须要反对插入自定义表情。评论性能很多人一开始跟我一样,第一个想到的就是用textarea,然而textarea是不反对的插入图片的,因为咱们的表情包是以图片的模式插入文本中的,所以这里是应用HTML5的新个性contenteditable,让div里的内容变成可编辑的。
demo地址
先看下效果图:
组件性能:
- 反对插入自定义表情
- 字符验证,超出局部主动切割(以字符进行计算,不是以长度进行计算,一个中文2个字符,一个字母1个字符,一个表情4个字符),因为咱们公司业务是应用字符进行字数统计,所以验证是用字符,须要用length能够自行批改
- 在光标地位精确插入表情
遇到的问题:(先列出问题,前面再写解决办法)
- 如果有对显示的内容进行强制更新时(如插入表情包,或者字符切割),光标会跑到文本最后面
- 无奈间接利用vue进行双向绑定,无奈用v-model与v-html进行数据双向绑定
- 定位光标地位,如果是在输出时就点击表情包 应该插入到最光标地位,如果不是在输出时候点击表情应该插入到文本到最初面
- 进行文本字节计算时对表情的解决,表情占位符如何保障惟一
- 超出字数限度如何切割 当超出的字符是表情的时候怎么切割
- 按下回车会插入<div><br></div>标签,或者会用<div>标签包裹住文本
- 按下空格符会有 ,以字符进行计算的话空格符 会被当成6个字符,实际上空格代表1个
兼容性问题:
- IE9以及局部Safari createContextualFragment 不兼容该办法
- window.getSelection , window.getSelection().getRangeAt这两个办法的兼容性,因为咱们的业务不须要兼容低版本的IE所以这里我就没做兼容
开始实现
html局部于css局部比较简单就不过多赘述,先不贴代码了,文末看残缺代码或者间接下载残缺小demo运行看看
参数
既然是封装成组件,那就须要从父组件那里接管一下数据
submitCmtLoading: { // 是否正在提交,如果是正在提交就不须要反复提交了 type: Boolean, default: false }, limtText: { // 限度的字符数,默认是0,0是不须要限度 type: Number, default: 0 }, iconWidth: { // 插入的表情宽度 type: Number, dafault: 24 }, iconHeight: { // 插入的表情高度 type: Number, dafault: 24 }, cmt_show: { // 是否显示组件 type: Boolean, default: true }, iconList: { // 表情包列表,以数组的模式且是必传项 type: Array, required: true }, placeholder: { // 提醒语 type: String, default: "踊跃回复可吸引更多人评论" }, info: { // 信息,这个看业务需要,如果须要对具体项在子组件中进行解决能够传递这个 type: Object }
因为还没贴上html代码,下文中呈现的this.$refs.cmt_input都代表输入框,既带属性contenteditable的元素
数据
data: function() { return { content: "", // 评论输出的内容 widFouce: "", // 用于定位输入框失焦点前的光标地位 rangeFouce: "", // 用于定位输入框失焦点前的光标地位 iconShow: false, // 是否显示表情列表 isSubmit: false // 是否提交 如果输出内容为空是不给提交的 }; }, watch: { cmt_show: { // 当组件显示时须要将光标定位到文末,不然第一次点击表情包会报错 handler(value) { if (value) { this.$nextTick(() => { this.toLast(this.$refs.cmt_input); // 将光标定位到文末 }); } }, immediate: true } },
在表情列表开展的时候,点击其余非表情列表区域要让这个表情列表隐没。
mounted() { const self = this; document .getElementsByClassName("g-doc")[0] .addEventListener("click", self.setHideClick); // g-doc是根节点,当然也能够换成body啥的,然而在组件销毁的时候要把工夫也给移除掉 self.$once("hook:beforeDestroy", () => { document.getElementsByClassName("g-doc")[0] && document .getElementsByClassName("g-doc")[0] .removeEventListener("click", self.setHideClick); }); }, // setHideClick 办法,放在method里的 setHideClick(event) { let target = event.srcElement; let nameList = ["icon_item", "icon_list", "icon_box"]; // 这个三个类名是整个表情包列表 if ( !nameList.includes(target.className) && !nameList.includes(target.parentElement.className) ) { // 如果不是表情包列表区域就敞开表情包列表面板 this.iconShow = false; } },
办法
几个简略点的办法,不过多的解说
**问题:如果有对显示的内容进行强制更新时(如插入表情包,或者字符切割),光标会跑到文本最后面
解决办法: 每次进行更新操作后调一次toLast(obj)办法,让光标回到最初面, 当然了,如果光标不在最初面的就不须要调这个办法了**
toLast(obj) { // 将光标移到最初,obj为输入框节点 if (window.getSelection) { let winSel = window.getSelection(); winSel.selectAllChildren(obj); winSel.collapseToEnd(); } }, blurInput() { // 失焦时触发的办法,失焦的时候保留光标地位 this.getFouceInput(); }, getFouceInput() { // 保留光标地位 this.widFouce = window.getSelection(); this.rangeFouce = this.widFouce.getRangeAt(0); }, showIconListPlane() { // 显示或者敞开表情包列表 // 显示表情包列表 if (!this.iconShow) { // 如果是关上,要保留一下光标地位 this.getFouceInput(); } this.iconShow = !this.iconShow; }, submitCmt() { // 提交评论 const self = this; let text = this.$refs.cmt_input.innerHTML; let length = this.getCharLen(this.paseText(text).text); if (self.submitCmtLoading || !self.isSubmit) return; if (self.cmt_text == "") { self.$emit("submitError", "评论为空"); // 不合乎规定时则抛出谬误的办法submitError return; } else if (this.limtText && length > this.limtText) { self.$emit("submitError", "评论超出字数"); // 不合乎规定时则抛出谬误的办法submitError return; } self.$emit("submitSuccess", text, self.info); // 验证通过则抛出正确的办法,且将文本与信息同时传递给父组件 }
字符验证
接下来讲一下字符验证的函数,因为光标相干的逻辑里有用到局部字符验证,所以先讲一下字符相干的办法。
判断文本占了多少个字符,这里传进来的文本是通过解决的,因为如果没解决过的文本里如果带表情包,而表情包是以img标签存在文本里的这样会占据很多个字符,所以会对表情包做一个占位符解决,让一个表情包只占4个字符,前面会讲这个。当初先看下判断字符个数的办法,因为如果是输出空格会变成 所以在这个办法里也会对这个进行解决
getCharLen(sSource) { // 空格 要当1个字符算,所以最初要给每个空格减去5 // 获取字符长度 var l = 0; var schar; for (var i = 0; (schar = sSource.charAt(i)); i++) { l += schar.match(/[^\x00-\xff]/) != null ? 2 : 1; } let nbsp = sSource.match(/ /gi); if (nbsp) { let len = nbsp.length; l = l - len * 5; } return l; },
应用占位符来代替表情包,因为1个表情包为4个字节,所以用4个数字来占位。这里应用随机获取工夫戳的4为数,而后与文本进行比拟,如果文本中存在,则递归再次获取,直到文本中不存在这4个数字。
对于回车键会让文本中增加div与br标签,因为咱们的评论是不容许手动换行的,所以把div标签与br标签都替换成两个空格“ ”留神不是
这也就解决了下面说的这三个问题
进行文本字节计算时对表情的解决,表情占位符如何保障惟一
- 按下回车会插入<div><br></div>标签,或者会用<div>标签包裹住文本
- 按下空格符会有 ,以字符进行计算的话空格符 会被当成6个字符,实际上空格代表1个
getRandomFour(str) { // 在工夫戳里取随机4位数作为key,且如果文本中蕴含key则递归 let timeStr = new Date().getTime() + ""; let result = ""; for (let i = 0; i < 4; i++) { let nums = Math.floor(Math.random() * 13); result = result + timeStr[nums]; } return str.indexOf(result) == -1 ? result : this.getRandomFour(str); }, paseText(str) { // 解析内容,把图片全副用占位替换掉 跟换行相干(div,br)全副改成两个空格 let str1 = str.replace(/<div>|<\/div>|<br>/gi, " "); let imgReg = /<img[^>]*src[=\'\"\s]+([^\"\']*)[\"\']?[^>]*>/gi; let imgMatch = str1.match(imgReg); let key = this.getRandomFour(str1); let isReplace = false; if (imgMatch) { isReplace = true; for (let i = 0; i < imgMatch.length; i++) { str1 = str1.replace(imgMatch[i], key); } } return { isReplace, // 这个是有没有进行占位符替换的标示,如果没有前面就不须要进行还原操作 key, text: str1 }; }, reductionStr(sourceText, text, key) { // 解析内容,把图片全副用占位替换掉 sourceText:原文本 text:替换后的文本 key:占位符标示 let imgReg = /<img[^>]*src[=\'\"\s]+([^\"\']*)[\"\']?[^>]*>/gi; let imgMatch = sourceText.match(imgReg); let result = text; if (imgMatch) { for (let i = 0; i < imgMatch.length; i++) { result = result.replace(key, imgMatch[i]); } } return result; },
当超出限度时进行文本切割,留神:如果是图片占位符,须要整个进行切割,不能切割局部,不然就会产生多余的文本 如 超出1个字符,然而最初为表情包,而表情包为4个数字的占位符,要切割的时候要把4个数字一起移除掉,不能只切除一个这样前面就跟key值匹配不上了。
这也就解决下面说的这个问题
- 超出字数限度如何切割 当超出的字符是表情的时候怎么切割
setCmtTextByLimit(sSource, key) { let self = this; // 文字切割 如果超出文字限度须要切割 if (typeof sSource !== "string") return; var str = changeLast(sSource, key); // 先判断最初是否为表情包 if (this.getCharLen(str) <= this.limtText) return str; while (this.getCharLen(str) > this.limtText) { str = 4 + str.lastIndexOf(key) == str.length ? str.substring(0, str.length - 4) : str.substring(0, str.length - 1); } function changeLast(strl, key) { // 始终切割到最初一个不为表情包或不超出限度为止 while ( 4 + strl.lastIndexOf(key) == strl.length && self.getCharLen(strl) > self.limtText ) { strl = sSource.substring(0, sSource.length - 4); } return strl; } return str; },
输出与光标
输出时要把文本实时传给父组件,且输出时要进行字符判断,超出时就不能输出。当然先要进行是否验证的判断,如果不须要验证则省略前面的一系列步骤,
changeText(e) { // 表情包插入时不触发该办法,只有输出时会触发 // 判断字数,要先把自定义表情改成占位符,一个自定义表情按俩2个字符算 let text = e.srcElement.innerHTML; let emitText = text; if (this.limtText) { let textObj = this.paseText(text); // 文本替换 let len = this.getCharLen(textObj.text); // 获取字符长度 if (len > this.limtText) { let str = this.setCmtTextByLimit(textObj.text, textObj.key); // 字符切割 emitText = textObj.isReplace ? this.reductionStr(text, str, textObj.key) : str; // 如果有进行替换 要将文本还原 e.srcElement.innerHTML = emitText; this.toLast(e.srcElement); // 进行强制批改内容后 要把光标定位到最初 } } this.isSubmit = emitText.length ? true : false; // 输入框不为空则能够提交 this.$emit("changeText", emitText); },
插入表情时,须要先进行判断加上4个字符是否超出字符限度,如果超出了就不给插入。而且这里要判断上一次光标的状态,如果上一次的光标是在输入框里,那么就在光标地位插入表情且将光标地位定位至该表情前方,如果上一次的光标不在输入框里,那么就间接在文末插入表情包。对于创立节点createContextualFragment在IE9跟局部safari浏览器中不兼容,能够改成createElement来进行创立,当然也能够都写成createElement,这里写createContextualFragment只是我懒得改哈哈哈。
解决问题:
- 定位光标地位,如果是在输出时就点击表情包 应该插入到最光标地位,如果不是在输出时候点击表情应该插入到文本到最初面
- IE9以及局部Safari createContextualFragment 不兼容该办法
insertIcon(url) { // 插入表情,url为表情地址 // 判断是否超出字数,如果超出不给插入 const self = this; this.isSubmit || (this.isSubmit = true); const length = this.getCharLen( this.paseText(this.$refs.cmt_input.innerHTML).text, ); if (this.limtText && length + 4 > this.limtText) return; const img = `<img src='${url}' width=${this.iconWidth} height=${this.iconHeight} />`; // 兼容性判断 如果不兼容不往下执行,尽管说不兼容IE9以下,然而还是做一下判断 不便前面灵便管制 if (window.getSelection && window.getSelection().getRangeAt) { const winSn = this.widFouce; let range = this.rangeFouce; // 要判断的光标状态,如果上一次光标不在输入框里,须要手动定位到输入框里 if ( winSn.focusNode.className !== 'content_edit' && winSn.focusNode.parentElement.className !== 'content_edit' ) { winSn.selectAllChildren(self.$refs.cmt_input); // 选中输入框里的元素 winSn.collapseToEnd(); // 定位光标至文末 range = winSn.getRangeAt(0); } range.collapse(false); let node; if (range.createContextualFragment) { // 兼容IE9跟safari,以下为创立img节点 node = range.createContextualFragment(img); } else { const tempDom = document.createElement('div'); tempDom.innerHTML = img; node = tempDom; } const dom = node.firstChild; range.insertNode(dom); // 将表情包节点增加进文本 const clRang = range.cloneRange(); // 复制range对象,留神这里复制的不是援用,所以在复制的对象上做批改不会影响到原对象 clRang.setStartAfter(dom); // 设置光标位于表情节点的前方,到这里文本里的表情还是选中状态,这里设置的是克隆后的range winSn.removeAllRanges(); // 移除选中状态 winSn.addRange(clRang); // 将克隆的range增加进去 self.$emit('changeText', self.$refs.cmt_input.innerHTML); // 因为监听input,无奈监听到表情包的输出,所以这里要再向父组件再抛出一次办法 } else { console.log('不兼容~'); } },
还有一个问题就是无奈间接利用vue进行双向绑定,无奈用v-model与v-html进行数据双向绑定:
因为输入框是通过contenteditable来实现,所以没方法用v-model进行文本内容的双向绑定,应用v-html也是没方法进行双向绑定的。所以这里是通过监听输出的input办法与插入表情包的办法来进行实时向上传递最新文本。
html代码
<!-- 父组件 --> <Comment :info="info" :submitCmtLoading="submitCmtLoading" :limtText="limtText" :iconWidth="iconWidth" :iconHeight="iconHeight" :cmt_show="cmt_show" :iconList="iconList" @changeText="changeText" @submitError="submitError" @submitSuccess="submitSuccess" /> <!-- 子组件 --> <div v-if="cmt_show" class="cmt_box"> <div ref="cmt_input" class="content_edit" contenteditable="true" :placeholder="placeholder" @focus="changeHandle" @input="changeText" @blur="blurInput" v-html="content" ></div> <div class="cmt_handle"> <img id="emoticon_icon" v-if="iconList.length" src="//www1.pconline.com.cn/20200929/pgc/cmt/icon.png" @click.stop="showIconListPlane" /> <div @click="submitCmt()" :class="['btn_submit', isSubmit ? 'btn_submit_y' : '']" > 公布 </div> <div v-show="iconShow" class="icon_list"> <ul class="icon_box"> <li class="icon_item" v-for="(item, index) in iconList" :key="`icon${index}`" @click="insertIcon(item)" > <img :src="item" /> </li> </ul> </div> </div> </div>
组件残缺代码
<template> <div v-if="cmt_show" class="cmt_box"> <div ref="cmt_input" class="content_edit" contenteditable="true" :placeholder="placeholder" @focus="changeHandle" @input="changeText" @blur="blurInput" v-html="content" ></div> <div class="cmt_handle"> <img id="emoticon_icon" v-if="iconList.length" src="//www1.pconline.com.cn/20200929/pgc/cmt/icon.png" @click.stop="showIconListPlane" /> <div @click="submitCmt()" :class="['btn_submit', isSubmit ? 'btn_submit_y' : '']" > 公布 </div> <div v-show="iconShow" class="icon_list"> <ul class="icon_box"> <li class="icon_item" v-for="(item, index) in iconList" :key="`icon${index}`" @click="insertIcon(item)" > <img :src="item" /> </li> </ul> </div> </div> </div></template><script>export default { name: "Comment", props: { submitCmtLoading: { type: Boolean, default: false }, limtText: { type: Number, default: 0 }, iconWidth: { type: Number, dafault: 24 }, iconHeight: { type: Number, dafault: 24 }, cmt_show: { type: Boolean, default: true }, iconList: { type: Array, required: true }, placeholder: { type: String, default: "踊跃回复可吸引更多人评论" }, info: { type: Object } }, data: function() { return { content: "", widFouce: "", rangeFouce: "", iconShow: false, isSubmit: false }; }, watch: { cmt_show: { handler(value) { if (value) { this.$nextTick(() => { console.log("this.$refs.cmt_input", this.$refs.cmt_input); this.toLast(this.$refs.cmt_input); }); } }, immediate: true } }, mounted() { const self = this; document.getElementById("app").addEventListener("click", self.setHideClick); self.$once("hook:beforeDestroy", () => { document.getElementById("app") && document .getElementById("app") .removeEventListener("click", self.setHideClick); }); }, methods: { setHideClick(event) { let target = event.srcElement; let nameList = ["icon_item", "icon_list", "icon_box"]; if ( !nameList.includes(target.className) && !nameList.includes(target.parentElement.className) ) { this.iconShow = false; } }, changeHandle() { console.log("blur"); }, changeText(e) { // 表情包插入时不触发该办法,只有输出时会触发 // 判断字数,要先把自定义表情改成占位符,一个自定义表情按俩2个字符算 let text = e.srcElement.innerHTML; let emitText = text; if (this.limtText) { let textObj = this.paseText(text); let len = this.getCharLen(textObj.text); if (len > this.limtText) { let str = this.setCmtTextByLimit(textObj.text, textObj.key); emitText = textObj.isReplace ? this.reductionStr(text, str, textObj.key) : str; e.srcElement.innerHTML = emitText; this.toLast(e.srcElement); } } this.isSubmit = emitText.length ? true : false; this.$emit("changeText", emitText); }, paseText(str) { // 解析内容,把图片全副用占位替换掉 跟换行相干(div,br)全副改成两个空格 let str1 = str.replace(/<div>|<\/div>|<br>/gi, " "); // eslint-disable-next-line no-useless-escape let imgReg = /<img[^>]*src[=\'\"\s]+([^\"\']*)[\"\']?[^>]*>/gi; let imgMatch = str1.match(imgReg); let key = this.getRandomFour(str1); let isReplace = false; if (imgMatch) { isReplace = true; for (let i = 0; i < imgMatch.length; i++) { str1 = str1.replace(imgMatch[i], key); } } return { isReplace, key, text: str1 }; }, /** * @description: 还原评论内容 */ reductionStr(sourceText, text, key) { // 解析内容,把图片全副用占位替换掉 // eslint-disable-next-line no-useless-escape let imgReg = /<img[^>]*src[=\'\"\s]+([^\"\']*)[\"\']?[^>]*>/gi; let imgMatch = sourceText.match(imgReg); let result = text; if (imgMatch) { for (let i = 0; i < imgMatch.length; i++) { result = result.replace(key, imgMatch[i]); } } return result; }, getRandomFour(str) { // 在工夫戳里取随机4位数作为key,且如果文本中蕴含key则递归 let timeStr = new Date().getTime() + ""; let result = ""; for (let i = 0; i < 4; i++) { let nums = Math.floor(Math.random() * 13); result = result + timeStr[nums]; } return str.indexOf(result) == -1 ? result : this.getRandomFour(str); }, getCharLen(sSource) { // 空格 要当1个字符算,所以最初要给每个空格减去5 // 获取字符长度 var l = 0; var schar; for (var i = 0; (schar = sSource.charAt(i)); i++) { // eslint-disable-next-line no-control-regex l += schar.match(/[^\x00-\xff]/) != null ? 2 : 1; } let nbsp = sSource.match(/ /gi); if (nbsp) { let len = nbsp.length; l = l - len * 5; } return l; }, setCmtTextByLimit(sSource, key) { let self = this; // 文字切割 如果超出文字限度须要切割 if (typeof sSource !== "string") return; var str = changeLast(sSource, key); if (this.getCharLen(str) <= this.limtText) return str; while (this.getCharLen(str) > this.limtText) { str = 4 + str.lastIndexOf(key) == str.length ? str.substring(0, str.length - 4) : str.substring(0, str.length - 1); } function changeLast(strl, key) { // 始终切割到最初一个不为表情包或不超出限度为止 while ( 4 + strl.lastIndexOf(key) == strl.length && self.getCharLen(strl) > self.limtText ) { strl = sSource.substring(0, sSource.length - 4); } return strl; } return str; }, showIconListPlane() { // 显示表情包列表 if (!this.iconShow) { this.getFouceInput(); } this.iconShow = !this.iconShow; // iconShow }, getFouceInput() { this.widFouce = window.getSelection(); this.rangeFouce = this.widFouce.getRangeAt(0); }, toLast(obj) { // 将光标移到最初 if (window.getSelection) { let range = window.getSelection(); range.selectAllChildren(obj); range.collapseToEnd(); } }, insertIcon(url) { // 判断是否超出字数,如果超出不给插入 const self = this; this.isSubmit || (this.isSubmit = true); let length = this.getCharLen( this.paseText(this.$refs.cmt_input.innerHTML).text ); if (this.limtText && length + 4 > this.limtText) return; const img = `<img src='${url}' width=${this.iconWidth} height=${this.iconHeight} />`; // 兼容性判断 如果不兼容不往下执行,尽管说不兼容IE9以下,然而还是做一下判断 不便前面灵便管制 if (window.getSelection && window.getSelection().getRangeAt) { let winSn = this.widFouce, range = this.rangeFouce; // 要判断的光标状态 if ( winSn.focusNode.className !== "content_edit" && winSn.focusNode.parentElement.className !== "content_edit" ) { winSn.selectAllChildren(self.$refs.cmt_input); winSn.collapseToEnd(); range = winSn.getRangeAt(0); } range.collapse(false); let node; if (range.createContextualFragment) { // 兼容IE9跟safari node = range.createContextualFragment(img); } else { let tempDom = document.createElement("div"); tempDom.innerHTML = img; node = tempDom; } let dom = node.firstChild; range.insertNode(dom); let clRang = range.cloneRange(); clRang.setStartAfter(dom); winSn.removeAllRanges(); winSn.addRange(clRang); self.$emit("changeText", self.$refs.cmt_input.innerHTML); } else { console.log("不兼容~"); } }, blurInput() { this.getFouceInput(); }, submitCmt() { // 提交评论 const self = this; let text = this.$refs.cmt_input.innerHTML; let length = this.getCharLen(this.paseText(text).text); if (self.submitCmtLoading || !self.isSubmit) return; if (self.cmt_text == "") { self.$emit("submitError", "评论为空"); return; } else if (this.limtText && length > this.limtText) { self.$emit("submitError", "评论超出字数"); return; } self.$emit("submitSuccess", text, self.info); } }};</script><style lang="scss" scoped>.cmt_box { width: 510px; height: 180px; background-color: #ffffff; border-radius: 2px; border: solid 1px #ececec; padding: 14px; box-sizing: border-box; margin: auto; .content_edit { width: 100%; height: 120px; outline: none; border: none; text-align: left; font-size: 14px; &:empty::before { color: #cccccc; content: attr(placeholder); } } .cmt_handle { display: flex; justify-content: space-between; align-items: center; position: relative; #emoticon_icon { width: 21px; height: 21px; display: block; flex-shrink: 0; cursor: pointer; } .btn_submit { width: 68px; height: 32px; background-color: #cccccc; border-radius: 16px; text-align: center; line-height: 32px; color: #ffffff; font-size: 14px; cursor: pointer; &.btn_submit_y { background-color: #f95354; } } .icon_list { position: absolute; top: 40px; left: -10px; width: 280px; border-radius: 2px; background: #fff; box-shadow: 0 5px 18px 0 rgba(0, 0, 0, 0.16); padding: 15px; &::before { content: ""; width: 0; height: 0; display: block; padding: 0; position: absolute; top: -10px; left: 10px; border-left: 10px solid transparent; border-right: 10px solid transparent; border-bottom: 10px solid #fff; } .icon_box { list-style: none; display: flex; justify-content: start; align-items: center; flex-wrap: wrap; padding: 0; margin: 0; .icon_item { padding: 5px; text-align: center; cursor: pointer; > img { width: 30px; height: 30px; } } } } }}</style>
最初再附上demo地址啦
自己前端小学生,欢送交换斧正~