遇到个需求是在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