contenteditable H5聊天室发送表情(2023.09.27更新)

2023-10-25 14:10

本文主要是介绍contenteditable H5聊天室发送表情(2023.09.27更新),希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

遇到个需求是在H5页面聊天室中可以发送表情,普通的发送信息已经做过了是借助的websocket,发表情类似于QQ微信那样,既需要展示在输入框中,又需要发送给后台,回显到聊天室让大家都看到,这个还是需要仔细考虑考虑的。

涉及到的功能点有以下几个:

1.仿照qq微信,输入框中要回显文字和表情,支持删除和插入。

2.输入框右侧有个表情按钮,点击按钮底部弹出表情区域,点击可以插入到“输入框”的光标位置。

3.输入框的高度有一定限制,超出后滚动。

对应的解决方案:

1.这里用普通的input或其他是显示不了表情的,需要借助div的contenteditable属性,经查证qq空间的动态也是用的这个,表情有两种方案:1.选用emoji表情,优点是全世界通用,无需解析使用简单,适配性强,缺点是个性化不高,观赏度不强。2.自己制定系统内的表情规则,比如动态解析展示,所谓的表情其实是图片,优点是可以DIY表情,可以根据自己项目风格设计,缺点是每次展示都需要解析,而且要处理删除逻辑等。在此选用方案1。

2.功能简单

3.功能简单

(2023.09.27补充)本文后续有更新,考虑到顺便记录解题过程,之前文章内容就暂不更改了,新增内容以追加方式由分割线显示,想要最终结果的直接翻到页面底部即可

技术栈vue2,核心代码参考如下:

// @/components/input.vue
<!-- 封装的变异输入框 -->
<template><div ref="editor" class="custom-input" contenteditable="true" @input="inputText" @blur="inputBlur" @focus="inputFocus"></div>
</template><script>
export default {props: ['value'],data() {return {isBlur: true, // 解决赋值时光标自动定位到起始位置}},watch: {value(val) {console.log(val);if (this.isBlur) {this.$refs.editor.innerHTML = val;}}},mounted() {document.execCommand("defaultParagraphSeparator", false, "")},methods: {// 获取标签内容getInnerHTML() {return this.$refs.editor.innerHTML},// 监听输入框内容inputText() {this.$emit('input', this.$refs.editor.innerHTML);},inputFocus() {this.isBlur = false;},inputBlur() {this.isBlur = true;this.$emit('input', this.$refs.editor.innerHTML);}}
}
</script><style lang="less" scoped>
.custom-input{width: 100%;max-height: 1.48rem;overflow-y: auto;line-height: 0.48rem;outline: #D3D3D3 auto 1px;padding-left: 1px;&:focus-visible {// outline: -webkit-focus-ring-color auto 1px;outline: #D3D3D3 auto 1px;}&:empty::before {content: attr(placeholder);font-size: 14px;color: #CCC;line-height: 21px;padding-top: 20px;}
}
</style>
// index.vue
<template>
<div class="chat-input-p" ref="chatInputP"><div class="chat-input"><customInput ref="customInput" v-model="chatValue"></customInput><van-icon name="smile-o" @click="showEmojiPanel = !showEmojiPanel"/><van-buttonv-if="chatValue !== '' && chatValue !== '<div><br></div>'"type="info"class="add-btn"@click="search">{{content.send}}</van-button><van-iconv-elsename="add-o"/></div><div v-if="showEmojiPanel" class="emoji-list-p"><div class="emoji-list"><div v-for="(item, index) in emojiList" :key="index" class="emoji-item" :style="{width: isPc?'0.57rem':'0.59rem',height: isPc?'0.57rem':'0.59rem'}" @click="pasteHtmlAtCaret(item)">{{ item }}</div></div></div></div>
</template>
<script>
import customInput from '@/components/input.vue'
export default {components: {customInput},data(){return {chatValue: '',emojiList: ['😀', '😄', '😅', '🤣', '😂', '😉', '😊', '😍', '😘', '😜','😝', '😏', '😒', '🙄', '😔', '😴', '😷', '🤮', '🥵', '😎','😮', '😰', '😭', '😱', '😩', '😡', '💀', '👽', '🤓', '🥳','😺', '😹', '😻', '🤚', '💩', '👍', '👎', '👏', '🙏', '💪'],showEmojiPanel: false, // 是否展示表情区域customInputHeight: 0, // 发言框高度}},watch: {chatValue: function() {// 由于输入框是div,可输入回车,所以要动态判断输入区域高度this.$nextTick(()=>{this.customInputHeight = this.$refs.chatInputP.offsetHeight || 0})},showEmojiPanel: function() {// 由于输入框是div,可输入回车,所以要动态判断输入区域高度this.$nextTick(()=>{this.customInputHeight = this.$refs.chatInputP.offsetHeight || 0})}},methods: {// 记录光标位置saveSelection() {if(window.getSelection) {let sel = window.getSelection();if(sel.getRangeAt && sel.rangeCount) {return sel.getRangeAt(0);}} else if(document.selection && document.selection.createRange) {return document.selection.createRange();}return null;},// 恢复光标位置restoreSelection(range) {if(range) {if(window.getSelection) {let sel = window.getSelection();sel.removeAllRanges();sel.addRange(range);} else if(document.selection && range.select) {range.select();}}},pasteHtmlAtCaret(html) {let sel, range;if (window.getSelection) {// IE9 and non-IEsel = window.getSelection();if (sel.getRangeAt && sel.rangeCount) {range = sel.getRangeAt(0);// 判断最后一次光标处是不是在输入框中,若不在则自动在输入框最后追加数据if(range.endContainer.className != 'custom-input' && range.endContainer.parentElement.className != 'custom-input' && range.endContainer.parentElement.parentElement.className != 'custom-input') {range = document.createRange();//用于设置 Range,使其包含一个 Node的内容。range.selectNodeContents(document.querySelector('.custom-input'));//将包含着的这段内容的光标设置到最后去,true 折叠到 Range 的 start 节点,false 折叠到 end 节点。如果省略,则默认为 false .range.collapse(false);sel = window.getSelection();sel.removeAllRanges();sel.addRange(range);}range = sel.getRangeAt(0);sel = window.getSelection();range.deleteContents();let el = document.createElement("div");el.innerHTML = html;let frag = document.createDocumentFragment(), node, lastNode;while ( (node = el.firstChild) ) {lastNode = frag.appendChild(node);}range.insertNode(frag);// Preserve the selectionif (lastNode) {range = range.cloneRange();range.setStartAfter(lastNode);range.collapse(true);sel.removeAllRanges();sel.addRange(range);}}} else if (document.selection && document.selection.type != "Control") {// IE < 9document.selection.createRange().pasteHTML(html);}this.chatValue = this.$refs.customInput.getInnerHTML()},}
}
</script>
<style lang="less" scoped>
.chat-input-p{.chat-input {min-height: 0.61rem;padding: 0.07rem 0 0.07rem 0.16rem;display: flex;align-items: flex-end;.van-search {width: 100%;padding: 0;.van-search__content {background-color: #fff;border: 1px solid #959595;}}.custom-input{width: 100%;max-height: 0.96rem;min-height: 0.48rem;font-size: 0.2rem;overflow-y: auto;line-height: 0.48rem;outline: #D3D3D3 auto 1px;padding-left: 1px;word-break: break-all;&:focus-visible {// outline: -webkit-focus-ring-color auto 1px;outline: #D3D3D3 auto 1px;}&:empty::before {content: attr(placeholder);font-size: 14px;color: #CCC;line-height: 21px;padding-top: 20px;}}.van-icon {font-size: 0.45rem;color: #959595;margin-right: 0.12rem;cursor: pointer;&:first-of-type {margin-left: 0.12rem;}}.add-btn {width: 0.89rem;height: 0.45rem;padding: 0;// margin-left: 0.16rem;margin-right: 0.16rem;}}.emoji-list-p{position: relative;height: 3.5rem;.emoji-list{display: flex;justify-content: flex-start;align-items: flex-start;flex-wrap: wrap;padding: 0.08rem;height: 100%;overflow-y: auto;.emoji-item{font-size: 0.3rem;display: flex;justify-content: center;align-items: center;cursor: pointer;user-select: none;}}.remove-p{position: absolute;bottom: 0;text-align: right;padding-right: 0.22rem;background-color: #FFF;}}
}
</style>

效果的话如图

 同时还兼顾了pc端的查看(细心的可以看到代码中有个变量isPc,就是判断pc端还是移动端的,pc端有滚动条,所以表情的大小稍微缩小留出滚动条位置)

因为是从整个项目中剥离出来的核心代码,可能有没见过的变量等,如果还有疑问可以留言。

再放一个emoji资源网址:😃 Smileys & People Emoji Meanings

--------------------------------------------------2023年9月27日更新-------------------------------------------

在使用过程中,也发现了一些问题,比如安卓手机上正常,到ios手机上会出现输入中文会导致连续添加两遍到输入框、删除出现异常等现象。

更新的核心代码如下:

// @/components/input.vue
<!-- 封装的变异输入框 -->
<template><div ref="editor" class="custom-input1" contenteditable="true" @click="onclick" @input="inputText" @blur="inputBlur" @focus="inputFocus"></div>
</template><script>
export default {props: ['value'],data() {return {// 真实数据位置realDomKeys: [],// 储存对应的字符串值dataList: [],// 光标位置focusOffset: 0,// 定义最后光标对象lastEditRange: null,chineseInput: false,emojiReg: /[\uD83C|\uD83D|\uD83E][\uDC00-\uDFFF][\u200D|\uFE0F]|[\uD83C|\uD83D|\uD83E][\uDC00-\uDFFF]|[0-9|*|#]\uFE0F\u20E3|[0-9|#]\u20E3|[\u203C-\u3299]\uFE0F\u200D|[\u203C-\u3299]\uFE0F|[\u2122-\u2B55]|\u303D|[\A9|\AE]\u3030|\uA9|\uAE|\u3030/gi}},watch: {value(val) {if (val != '') {const html = this.$refs.editor.innerHTMLif(html != val){this.$refs.editor.innerHTML = val;}}else {// 外部传来空字符串,可能是点发送后清空了输入框内容,此时需要把其他数据都清掉this.$refs.editor.innerHTML = ''this.realDomKeys = []this.dataList = []this.focusOffset = 0this.lastEditRange = null}}},mounted() {// document.execCommand("defaultParagraphSeparator", false, "")const el = this.$refs.editorel.addEventListener('compositionstart', this.divCompositionstart, false)el.addEventListener('compositionend', this.divCompositionend, false)},beforeDestroy() {const el = this.$refs.editorel.removeEventListener('compositionstart', this.divCompositionstart, false)el.removeEventListener('compositionend', this.divCompositionend, false)},methods: {setDataList(html, type) {// 记录数据存储位置(指的是数据所在数组位置的下标值)let cursorJS// 记录光标存在位置(指的是当前光标前的数据个数)let cursorDom = this.focusOffset// 判断光标所处位置,如果在中文体内,则放到中文右括号侧,其余情况确认光标的真实指向// 光标是否位于最末尾if (cursorDom < this.realDomKeys.length && cursorDom > 0) {// 判断光标是否在中文体内,如果在就让光标落到此中文最右边(右括号侧if (this.realDomKeys[cursorDom] instanceof Object) {//  在中文体内// 记录光标应该在的位置和对应此刻数据存储位置if (cursorDom == this.realDomKeys[cursorDom].start) {// console.log('@@光标在中文体旁边')} else {cursorDom = this.realDomKeys[cursorDom].start + this.realDomKeys[cursorDom].n// console.log('@@光标在中文体内')}cursorJS = this.getCursorJS(cursorDom) - 1 //取的是当前指向数据的下标值} else {//  不在中文体内cursorJS = this.getCursorJS(cursorDom) - 1// console.log('@@光标在在中文体外,且在数组内,真实数据位置为', cursorJS)}} else if (cursorDom == this.realDomKeys.length) {// 位于最末尾cursorJS = this.dataList.length - 1} else if (cursorDom == 0) {// 位于最前端cursorJS = -1}// 增减datalist数据if (html instanceof Object) {// this.dataList.push(val.name)} else if (html == 'DEL') {// 删除数据let strif (cursorJS != -1) {//如果在最前端刪除無效,即cursorJS=-1和0str = this.dataList[cursorJS]this.dataList.splice(cursorJS, 1)this.setRealDomKeys()if (this.emojiReg.test(str)) {cursorDom = cursorDom - str.length} else {cursorDom--}}} else {//添加数据this.dataList.splice(cursorJS == -1 ? 0 : cursorJS + 1, 0, html)this.setRealDomKeys()if (type) {cursorDom += 2} else {cursorDom++}}this.focusOffset = cursorDomthis.keepLastIndex(this.$refs.editor)},// 重新计算dataList的对应realDomKeyssetRealDomKeys() {const _this = thisthis.realDomKeys = []this.dataList.forEach((item, index) => {//判断是否为设备名// console.log(this.emojiReg.test(item), 'this.emojiReg.test(item)');if (_this.emojiReg.test(item)) {//凡是包含中文,以及字符串长度大于1的都默认为设备名let len = item.lengthlet i = 0let reaLen = _this.realDomKeys.lengthwhile (i < len) {_this.realDomKeys.push({index: index, //对应数据数组的下标值start: reaLen, //此数据在realDomKeys起始下标n: len, //共占有多少数据格})i++}} else {_this.realDomKeys.push(index)}})},// 获取当光标不在中文体内时,对应的数据位置getCursorJS(cursorDom) {let count = 0let i = 0while (i < cursorDom) {if (this.realDomKeys[i] instanceof Object) {count++i += this.realDomKeys[i].n} else {count++i++}}return count},keepLastIndex(obj) {if (window.getSelection) {obj.focus()// 获取选定对象var selection = getSelection()if (this.lastEditRange) {// 存在最后光标对象,选定对象清除所有光标并添加最后光标还原之前的状态selection.removeAllRanges()selection.addRange(this.lastEditRange)}obj.innerHTML = this.getHtml()// console.log(selection.anchorNode.childNodes[0], this.focusOffset);selection.collapse(selection.anchorNode.childNodes[0], this.focusOffset)}},// 将存储数据转化成htmlgetHtml() {let str = ''this.dataList.forEach((item) => {str += item})return str},// 點擊editor時獲取光標位置onclick(e) {let selection = window.getSelection()this.lastEditRange = selection.getRangeAt(0)this.focusOffset = selection.focusOffset},// 获取标签内容getInnerHTML() {return this.$refs.editor.innerHTML},// 监听输入框内容inputText(e) {setTimeout(()=>{if(this.chineseInput) returnlet textif (!e.data && e.inputType == 'deleteContentBackward') {// 只有手动退格的inputType是deleteContentBackward,防止ios下输入中文会导致误删文字this.setDataList('DEL', 0)} else if(e.inputType == 'insertText' || e.inputType == 'insertCompositionText') {// 安卓输入汉字的inputType为insertText,ios输入汉字的inputType为insertCompositionText和insertFromComposition,因此会导致ios会多输出一遍,因此ios的只取一个即可text = e.data}if (text) {for(let val of text.split('')){this.setDataList(val, 0)}}else{this.keepLastIndex(this.$refs.editor)}// this.$nextTick(()=>{this.$emit('input', this.$refs.editor.innerHTML);// })}, 0)},divCompositionstart () {// 表明在中文输入中,防止ios下把拼音也当做文字记录this.chineseInput = true},divCompositionend (e) {// 表明中文输入结束,防止ios下把拼音也当做文字记录this.chineseInput = false},inputFocus() {},inputBlur() {this.$emit('input', this.$refs.editor.innerHTML);this.$emit('blur')}}
}
</script><style lang="less" scoped>
.custom-input1{width: 100%;max-height: 1.48rem;overflow-y: auto;line-height: 0.48rem;outline: #D3D3D3 auto 1px;padding-left: 1px;-webkit-user-select: text;
}
</style>
// index.vue
<template>
<div class="chat-input-p" ref="chatInputP"><div class="chat-input"><customInput ref="customInput" v-model="chatValue"></customInput><van-icon name="smile-o" @click="showEmojiPanel = !showEmojiPanel"/><van-buttonv-if="chatValue !== '' && chatValue !== '<div><br></div>'"type="info"class="add-btn"@click="search">发送</van-button><van-iconv-elsename="add-o"/></div><div v-if="showEmojiPanel" class="emoji-list-p"><div class="emoji-list"><div v-for="(item, index) in emojiList" :key="index" class="emoji-item" :style="{width: isPc?'0.57rem':'0.59rem',height: isPc?'0.57rem':'0.59rem'}" @click="addEmoji(item)">{{ item }}</div></div></div></div>
</template>
<script>
import customInput from '@/components/input.vue'
export default {components: {customInput},data(){return {chatValue: '',emojiList: ['😀', '😄', '😅', '🤣', '😂', '😉', '😊', '😍', '😘', '😜','😝', '😏', '😒', '🙄', '😔', '😴', '😷', '🤮', '🥵', '😎','😮', '😰', '😭', '😱', '😩', '😡', '💀', '👽', '🤓', '🥳','😺', '😹', '😻', '🤚', '💩', '👍', '👎', '👏', '🙏', '💪'],showEmojiPanel: false, // 是否展示表情区域customInputHeight: 0, // 发言框高度}},watch: {chatValue: function() {// 由于输入框是div,可输入回车,所以要动态判断输入区域高度this.$nextTick(()=>{this.customInputHeight = this.$refs.chatInputP.offsetHeight || 0})},showEmojiPanel: function() {// 由于输入框是div,可输入回车,所以要动态判断输入区域高度if(!this.isPc){if(this.showEmojiPanel){// 如果开启表情区域,则自动聚焦}this.$nextTick(()=>{this.customInputHeight = this.$refs.chatInputP.offsetHeight || 0})}}},methods: {// 增加表情addEmoji(html) {this.$refs.customInput.setDataList(html, 1)},}
}
</script>
<style lang="less" scoped>
.chat-input-p{.chat-input {min-height: 0.61rem;padding: 0.07rem 0 0.07rem 0.16rem;display: flex;align-items: flex-end;.van-search {width: 100%;padding: 0;.van-search__content {background-color: #fff;border: 1px solid #959595;}}.custom-input{width: 100%;max-height: 0.96rem;min-height: 0.48rem;font-size: 0.2rem;overflow-y: auto;line-height: 0.48rem;outline: #D3D3D3 auto 1px;padding-left: 1px;word-break: break-all;&:focus-visible {// outline: -webkit-focus-ring-color auto 1px;outline: #D3D3D3 auto 1px;}&:empty::before {content: attr(placeholder);font-size: 14px;color: #CCC;line-height: 21px;padding-top: 20px;}}.van-icon {font-size: 0.45rem;color: #959595;margin-right: 0.12rem;cursor: pointer;&:first-of-type {margin-left: 0.12rem;}}.add-btn {width: 0.89rem;height: 0.45rem;padding: 0;// margin-left: 0.16rem;margin-right: 0.16rem;}}.emoji-list-p{position: relative;height: 3.5rem;.emoji-list{display: flex;justify-content: flex-start;align-items: flex-start;flex-wrap: wrap;padding: 0.08rem;height: 100%;overflow-y: auto;.emoji-item{font-size: 0.3rem;display: flex;justify-content: center;align-items: center;cursor: pointer;user-select: none;}}.remove-p{position: absolute;bottom: 0;text-align: right;padding-right: 0.22rem;background-color: #FFF;}}
}
</style>

参考资料:

Vue使用Emoji表情_清新小伙子的博客-CSDN博客_vue使用emoji

https://www.jb51.net/article/246976.htm

contentEditable,window.getSelection详解---可编辑div,容器,设置/获取光标位置,光标处插入内容及光标的操作_千拾的博客-CSDN博客_contenteditable 获取光标位置

https://www.jianshu.com/p/a026014012e2

这篇关于contenteditable H5聊天室发送表情(2023.09.27更新)的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/283099

相关文章

poj3468(线段树成段更新模板题)

题意:包括两个操作:1、将[a.b]上的数字加上v;2、查询区间[a,b]上的和 下面的介绍是下解题思路: 首先介绍  lazy-tag思想:用一个变量记录每一个线段树节点的变化值,当这部分线段的一致性被破坏我们就将这个变化值传递给子区间,大大增加了线段树的效率。 比如现在需要对[a,b]区间值进行加c操作,那么就从根节点[1,n]开始调用update函数进行操作,如果刚好执行到一个子节点,

hdu1394(线段树点更新的应用)

题意:求一个序列经过一定的操作得到的序列的最小逆序数 这题会用到逆序数的一个性质,在0到n-1这些数字组成的乱序排列,将第一个数字A移到最后一位,得到的逆序数为res-a+(n-a-1) 知道上面的知识点后,可以用暴力来解 代码如下: #include<iostream>#include<algorithm>#include<cstring>#include<stack>#in

hdu1689(线段树成段更新)

两种操作:1、set区间[a,b]上数字为v;2、查询[ 1 , n ]上的sum 代码如下: #include<iostream>#include<algorithm>#include<cstring>#include<stack>#include<queue>#include<set>#include<map>#include<stdio.h>#include<stdl

hdu 1754 I Hate It(线段树,单点更新,区间最值)

题意是求一个线段中的最大数。 线段树的模板题,试用了一下交大的模板。效率有点略低。 代码: #include <stdio.h>#include <string.h>#define TREE_SIZE (1 << (20))//const int TREE_SIZE = 200000 + 10;int max(int a, int b){return a > b ? a :

AI行业应用(不定期更新)

ChatPDF 可以让你上传一个 PDF 文件,然后针对这个 PDF 进行小结和提问。你可以把各种各样你要研究的分析报告交给它,快速获取到想要知道的信息。https://www.chatpdf.com/

GIS图形库更新2024.8.4-9.9

更多精彩内容请访问 dt.sim3d.cn ,关注公众号【sky的数孪技术】,技术交流、源码下载请添加微信:digital_twin123 Cesium 本期发布了1.121 版本。重大新闻,Cesium被Bentley收购。 ✨ 功能和改进 默认启用 MSAA,采样 4 次。若要关闭 MSAA,则可以设置scene.msaaSamples = 1。但是通过比较,发现并没有多大改善。

JavaFX应用更新检测功能(在线自动更新方案)

JavaFX开发的桌面应用属于C端,一般来说需要版本检测和自动更新功能,这里记录一下一种版本检测和自动更新的方法。 1. 整体方案 JavaFX.应用版本检测、自动更新主要涉及一下步骤: 读取本地应用版本拉取远程版本并比较两个版本如果需要升级,那么拉取更新历史弹出升级控制窗口用户选择升级时,拉取升级包解压,重启应用用户选择忽略时,本地版本标志为忽略版本用户选择取消时,隐藏升级控制窗口 2.

EasyPlayer.js网页H5 Web js播放器能力合集

最近遇到一个需求,要求做一款播放器,发现能力上跟EasyPlayer.js基本一致,满足要求: 需求 功性能 分类 需求描述 功能 预览 分屏模式 单分屏(单屏/全屏) 多分屏(2*2) 多分屏(3*3) 多分屏(4*4) 播放控制 播放(单个或全部) 暂停(暂停时展示最后一帧画面) 停止(单个或全部) 声音控制(开关/音量调节) 主辅码流切换 辅助功能 屏

记录每次更新到仓库 —— Git 学习笔记 10

记录每次更新到仓库 文章目录 文件的状态三个区域检查当前文件状态跟踪新文件取消跟踪(un-tracking)文件重新跟踪(re-tracking)文件暂存已修改文件忽略某些文件查看已暂存和未暂存的修改提交更新跳过暂存区删除文件移动文件参考资料 咱们接着很多天以前的 取得Git仓库 这篇文章继续说。 文件的状态 不管是通过哪种方法,现在我们已经有了一个仓库,并从这个仓

消除安卓SDK更新时的“https://dl-ssl.google.com refused”异常的方法

消除安卓SDK更新时的“https://dl-ssl.google.com refused”异常的方法   消除安卓SDK更新时的“https://dl-ssl.google.com refused”异常的方法 [转载]原地址:http://blog.csdn.net/x605940745/article/details/17911115 消除SDK更新时的“