遇到个需求是在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
- <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 !== '
'" - 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
- <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 !== '
'" - 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