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-button
		v-if="chatValue !== '' && chatValue !== '<div><br></div>'"
		type="info"
		class="add-btn"
		@click="search"
	  >{{content.send}}</van-button>
	  <van-icon
		v-else
		name="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-IE
			sel = 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 selection
			  if (lastNode) {
				range = range.cloneRange();
				range.setStartAfter(lastNode);
				range.collapse(true);
				sel.removeAllRanges();
				sel.addRange(range);
			  }
			}
		  } else if (document.selection && document.selection.type != "Control") {
			// IE < 9
			document.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.innerHTML
        if(html != val){
          this.$refs.editor.innerHTML = val;
        }
      }else {
        // 外部传来空字符串,可能是点发送后清空了输入框内容,此时需要把其他数据都清掉
        this.$refs.editor.innerHTML = ''
        this.realDomKeys = []
        this.dataList = []
        this.focusOffset = 0
        this.lastEditRange = null
      }
    }
  },
  mounted() {
    // document.execCommand("defaultParagraphSeparator", false, "")
    const el = this.$refs.editor
    el.addEventListener('compositionstart', this.divCompositionstart, false)
    el.addEventListener('compositionend', this.divCompositionend, false)
  },
  beforeDestroy() {
    const el = this.$refs.editor
    el.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 str
        if (cursorJS != -1) {
          //如果在最前端刪除無效,即cursorJS=-1和0
          str = 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 = cursorDom
      this.keepLastIndex(this.$refs.editor)
    },
    // 重新计算dataList的对应realDomKeys
    setRealDomKeys() {
      const _this = this
      this.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.length
          let i = 0
          let reaLen = _this.realDomKeys.length
          while (i < len) {
            _this.realDomKeys.push({
              index: index, //对应数据数组的下标值
              start: reaLen, //此数据在realDomKeys起始下标
              n: len, //共占有多少数据格
            })
            i++
          }
        } else {
          _this.realDomKeys.push(index)
        }
      })
    },
    // 获取当光标不在中文体内时,对应的数据位置
    getCursorJS(cursorDom) {
      let count = 0
      let i = 0
      while (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)
      }
    },
    // 将存储数据转化成html
    getHtml() {
      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) return
        let text
        if (!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-button
		v-if="chatValue !== '' && chatValue !== '<div><br></div>'"
		type="info"
		class="add-btn"
		@click="search"
	  >发送</van-button>
	  <van-icon
		v-else
		name="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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值