仿微信聊天的选择微信表情、@成员功能,当时写的时候时间比较赶,代码可能会有点乱,效果没有微信的那么流畅,凑合着看吧
案例的仓库地址:gitee.com/mayxue/vue2…
一、效果
1.1 选择表情包
表情包雪碧图
1.2 @成员
二、实现过程
2.1 页面布局-sendBox.vue
2.1.1 页面布局(sendBox.vue)
<template>
<div class="chatToolsPane" @click="closeWxEmojisDialogShow">
<!-- chatTools-工具-start -->
<div class="chatTools">
<!-- 表情包选择-组件-弹窗-start -->
<wxEmojisDialog
v-if="wxEmojisDialogShow"
@wxEmojisClick="wxEmojisClick"
></wxEmojisDialog>
<!-- 表情包选择-组件-弹窗-end -->
<!-- 聊天操作-start -->
<div class="chatOperation">
<div class="toolBtn">
<!-- 表情包按钮-start -->
<span
class="expressionBtn"
@click.stop="wxEmojisDialogShow = true"
>
<!-- <svg-icon icon-class="expression" /> -->
<img src="@/assets/images/emojisIco.png" />
</span>
<!-- 表情包按钮-end -->
</div>
<div class="chatTextareaDiv">
<div
contentEditable="true"
contenteditable="plaintext-only"
ref="chatTextarea"
id="chatTextarea"
class="chatTextarea"
@keyup="handkeKeyUp"
@keydown="handleKeyDown"
@blur="chatTextareaClick"
></div>
<div class="chatSubmitBtn">
<el-button
type="primary"
size="small"
@click="chatSubmit()"
>发送</el-button
>
</div>
</div>
</div>
<!-- 聊天操作-end -->
</div>
<!-- chatTools-工具-end -->
<!-- @成员弹窗-start -->
<atDialog
v-if="atDialogShow"
:visible="atDialogShow"
:position="position"
:queryString="queryString"
:mockList="groupUserData"
@onPickUser="handlePickUser"
@onHide="handleHide"
@onShow="handleShow"
></atDialog>
<!-- @成员弹窗-end -->
</div>
</template>
<script>
import { mapGetters } from "vuex";
import { wxEmojis } from "@/utils/wxEmojis";
import sendChatMsg from "@/utils/sendChatMsg";
import { atDialog, wxEmojisDialog } from "./components/index";
export default {
name: "sandBox",
components: { atDialog, wxEmojisDialog },
data() {
return {
wxEmojisDialogShow: false, //表情包选择弹窗状态
node: "", // 获取到节点
groupUser: "", // 选中项的内容
endIndex: "", // 光标最后停留位置
queryString: "", // 搜索值
atDialogShow: false, // 是否显示弹窗
position: {
x: 0,
y: 0,
}, // 弹窗显示位置
chatTextarea: "",
//@成员数据
groupUserData: [
{ nick_name: "HTML", user_id: "HTML" },
{ nick_name: "CSS", user_id: "CSS" },
{ nick_name: "Java", user_id: "Java" },
{ nick_name: "JavaScript", user_id: "JavaScript" },
],
};
},
computed: {
...mapGetters(["wxEmojisData"]),
},
watch: {
queryString(val) {
if (val) {
//模糊查询
this.mockList = this.groupUserData.filter(({ nick_name }) =>
nick_name.includes(val)
);
} else {
this.mockList = this.groupUserData.slice(0);
}
this.position = this.getRangeRect(this.mockList.length);
console.log("watch-mockList", this.mockList);
},
},
methods: {
//关闭微信表情包弹窗
closeWxEmojisDialogShow() {
this.wxEmojisDialogShow = false;
},
//微信默认表情包回显
wxEmojis(html) {
return wxEmojis(html);
},
//点击表情包
wxEmojisClick(item) {
let wxEmojis = this.wxEmojis(item);
console.log("wxEmojis", wxEmojis);
this.$refs.chatTextarea.focus();
this.insertHtmlAtCaret(wxEmojis);
},
//点击聊天框
chatTextareaClick() {
//this.$refs.chatTextarea.focus();
// 获取选定对象
var selection = getSelection();
// 设置最后光标对象
this.chatBlurIndex = selection.getRangeAt(0);
},
//发送消息
chatSubmit() {
this.chatTextarea = this.$refs.chatTextarea.innerHTML;
let atNode = document.querySelectorAll(".chatTextarea span"); //获取所有的@成员节点
let userArray = [];
console.log("atNode", atNode);
for (let i = 0; i < atNode.length; i++) {
if (atNode[i].getAttribute("data-user")) {
userArray.push(
JSON.parse(atNode[i].getAttribute("data-user")).user_id
);
}
}
let wxEmojisNode = document.querySelectorAll(".chatTextarea i"); //获取所有的表情包节点
//遍历表情包,将表情包节点替换成转义表情包字符:[微笑]
for (let i = 0; i < wxEmojisNode.length; i++) {
for (let j = 0; j < this.wxEmojisData.length; j++) {
if (
wxEmojisNode[i].getAttribute("data-value") ==
this.wxEmojisData[j]
) {
//将表情包节点替换成转义表情包字符:[微笑]
this.chatTextarea = this.chatTextarea.replace(
wxEmojisNode[i].outerHTML.toString(),
this.wxEmojisData[j]
);
}
}
}
console.log("userArray", userArray);
console.log(
"发送消息this.chatTextarea",
this.trimHtml(this.chatTextarea)
);
},
//从html代码中获取纯文本
trimHtml(str) {
return sendChatMsg.trimHtml(str);
},
// 获取光标位置
getCursorIndex() {
return sendChatMsg.getCursorIndex();
},
// 获取节点
getRangeNode() {
return sendChatMsg.getRangeNode();
},
// @成员弹窗出现的位置
getRangeRect() {
return sendChatMsg.getRangeRect();
},
// 是否展示 @
showAt() {
return sendChatMsg.showAt();
},
// 获取 @ 用户
getAtUser() {
return sendChatMsg.getAtUser();
},
//选择表情包后在聊天框内插入html
insertHtmlAtCaret(html) {
return sendChatMsg.insertHtmlAtCaret(html, this.chatBlurIndex);
},
// 插入@标签
replaceAtUser(replaceHtml, that) {
return sendChatMsg.replaceAtUser(replaceHtml, that);
},
// 键盘抬起事件
handkeKeyUp() {
if (this.showAt()) {
const node = this.getRangeNode();
const endIndex = this.getCursorIndex();
this.node = node;
this.endIndex = endIndex;
this.position = this.getRangeRect();
this.queryString = this.getAtUser() || "";
this.atDialogShow = true;
} else {
this.atDialogShow = false;
}
},
// 键盘按下事件
handleKeyDown(e) {
if (this.atDialogShow) {
if (
e.code === "ArrowUp" ||
e.code === "ArrowDown" ||
e.code === "Enter"
) {
e.preventDefault();
}
}
},
// 插入标签后隐藏选择框
handlePickUser(groupUser) {
let that = this;
this.replaceAtUser(groupUser, that);
this.groupUser = groupUser;
this.atDialogShow = false;
},
// 隐藏选择框
handleHide() {
this.atDialogShow = false;
},
// 显示选择框
handleShow() {
this.atDialogShow = true;
},
},
};
</script>
<style scoped lang="scss">
//chatTools-工具
div:focus {
outline: none;
}
.chatToolsPane {
margin: 400px 200px 0 200px;
border: 1px solid #e6e6e6;
width: 690px;
height: 160px;
}
.chatTools {
height: 148px;
position: relative;
.joinChat {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
margin-top: 40px;
.joinChatBtn {
width: 100%;
text-align: center;
margin-top: 15px;
}
}
.svg-icon {
width: 20px !important;
height: 20px !important;
}
//聊天操作-start
.chatOperation {
padding: 10px 20px;
.toolBtn {
span {
display: inline-block;
margin-right: 15px;
cursor: pointer;
}
}
}
//聊天框-发送消息-start
.chatTextareaDiv {
position: relative;
height: 110px;
::v-deep .el-textarea__inner,
.chatTextarea {
height: 70px;
padding: 5px 0;
border: none;
color: #333;
resize: none;
background: none;
line-height: 22px;
position: relative;
overflow-y: auto;
word-wrap: break-word;
width: 651px;
}
.chatTextarea::-webkit-scrollbar {
width: 6px;
}
.chatTextarea::-webkit-scrollbar-track {
background-color: #fff;
border-radius: 10px;
}
.chatTextarea::-webkit-scrollbar-thumb {
background-color: #c1c1c1;
border-radius: 20px;
}
::v-deep .el-textarea__inner::-webkit-scrollbar {
width: 6px;
}
::v-deep .el-textarea__inner::-webkit-scrollbar-track {
background-color: #fff;
border-radius: 10px;
}
::v-deep .el-textarea__inner::-webkit-scrollbar-thumb {
background-color: #c1c1c1;
border-radius: 20px;
}
.chatSubmitBtn {
position: absolute;
bottom: 5px;
right: 0;
::v-deep .el-button--small {
padding: 7px 30px;
}
}
}
//聊天框-发送消息-end
}
</style>
<style lang="scss">
.chatTextarea {
//微信默认表情
.wxEmojis {
display: inline-block;
width: 24px;
height: 24px;
background: url("~@/assets/images/wxEmojis.png") top left
no-repeat;
// background-position: -3px -3px;
vertical-align: bottom;
margin: 0 1px;
}
i {
font-style: normal;
}
}
</style>
2.1.2 @成员弹窗组件布局(atDialog.vue)
<template>
<!-- @成员弹窗 -->
<div
class="atDialog"
:style="{
position: 'fixed',
top: position.y + 'px',
left: position.x + 'px',
}"
>
<div class="groupUserList">
<div
v-for="(item, i) in mockList"
:key="item.user_name"
class="item"
:class="{ active: i === index }"
ref="usersRef"
@click="clickAt($event, item)"
>
<div class="avatar"><img :src="item.head_img" /></div>
<div class="name">{{ item.nick_name }}</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: "AtDialog",
props: {
visible: Boolean,
position: Object,
queryString: String,
mockList: {
type: Array,
default: [],
},
},
data() {
return {
users: [],
index: -1,
};
},
watch: {},
mounted() {
document.addEventListener("keyup", this.keyDownHandler);
},
destroyed() {
document.removeEventListener("keyup", this.keyDownHandler);
},
methods: {
keyDownHandler(e) {
if (e.code === "Escape") {
this.$emit("onHide");
return;
}
// 键盘按下 => ↓
if (e.code === "ArrowDown") {
if (this.index >= this.mockList.length - 1) {
this.index = 0;
} else {
this.index = this.index + 1;
}
}
// 键盘按下 => ↑
if (e.code === "ArrowUp") {
if (this.index <= 0) {
this.index = this.mockList.length - 1;
} else {
this.index = this.index - 1;
}
}
// 键盘按下 => 回车
if (e.code === "Enter") {
if (this.mockList.length) {
const user = {
nick_name: this.mockList[this.index].nick_name,
user_name: this.mockList[this.index].user_name,
};
this.$emit("onPickUser", user);
this.index = -1;
}
}
},
clickAt(e, item) {
const groupUser = {
nick_name: item.nick_name,
user_name: item.user_name,
};
this.$emit("onPickUser", groupUser);
this.index = -1;
},
},
created() {},
};
</script>
<style scoped lang="scss">
.atDialog {
.groupUserList {
width: 200px;
background: #fff;
// box-shadow: 0 2px 12px 0 rgb(0 0 0 / 10%);
box-sizing: border-box;
overflow-y: auto;
overflow-x: hidden;
max-height: 175px;
box-shadow: 0px 0px 14px 0px rgba(162, 162, 162, 0.5);
}
.groupUserList::-webkit-scrollbar {
width: 6px;
}
.groupUserList::-webkit-scrollbar-track {
border-radius: 10px;
}
.groupUserList::-webkit-scrollbar-thumb {
background-color: #c1c1c1;
border-radius: 20px;
}
.empty {
font-size: 14px;
padding: 0 20px;
color: #999;
}
.item {
font-size: 14px;
padding: 0 0 0 5px;
cursor: pointer;
color: #333;
display: flex;
align-items: center;
height: 35px;
background: #fff;
.avatar {
flex-basis: 30px;
img {
width: 25px;
height: 25px;
object-fit: cover;
vertical-align: bottom;
}
}
&:hover,
&.active {
background: #ececee;
}
.id {
font-size: 12px;
color: rgb(83, 81, 81);
}
}
}
</style>
2.1.3 微信默认表情包弹窗组件(wxEmojisDialog.vue)
<template>
<!-- 微笑表情包选择弹窗 -->
<div class="wxEmojisDialog">
<div class="pointIco"></div>
<div class="wxEmojisList">
<div
class="wxEmojisItem"
v-for="(item, index) of wxEmojisData"
:key="index"
@click="wxEmojisClick(item)"
v-html="wxEmojis(item)"
></div>
</div>
</div>
</template>
<script>
import { mapGetters } from "vuex";
import { wxEmojis } from "@/utils/wxEmojis";
export default {
components: {},
data() {
return {};
},
computed: {
...mapGetters(["wxEmojisData"]),
},
methods: {
//微信默认表情包回显
wxEmojis(html) {
return wxEmojis(html);
},
//选择表情包
wxEmojisClick(item) {
console.log("item", item);
this.$emit("wxEmojisClick", item);
},
},
created() {},
};
</script>
<style lang="scss">
.wxEmojisDialog {
position: absolute;
top: 0;
left: 0;
z-index: 100;
margin-top: -303px;
background: #fff;
border-radius: 10px;
margin-left: -150px;
padding: 5px 2px 5px 5px;
box-shadow: 0px 0px 14px 0px rgba(162, 162, 162, 0.5);
.pointIco {
position: absolute;
bottom: 0;
left: 50%;
margin-left: -8px;
margin-bottom: -16px;
width: 0px;
height: 0px;
border-top: #fff 8px solid;
border-left: transparent 8px solid;
border-right: transparent 8px solid;
border-bottom: transparent 8px solid;
}
.wxEmojisList {
display: flex;
flex-wrap: wrap;
width: 358px;
height: 282px;
overflow-y: auto;
border-radius: 10px;
.wxEmojisItem {
//微信默认表情
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
margin: 5px;
cursor: pointer;
border-radius: 4px;
.wxEmojis {
display: inline-block;
width: 24px;
height: 24px;
background: url("~@/assets/images/wxEmojis.png") top left
no-repeat;
// background-position: -3px -3px;
vertical-align: bottom;
margin: 0 1px;
transition: 0.2s;
}
&:hover {
background: #f2f2f2;
.wxEmojis {
transform: scale(1.1);
}
}
}
}
.wxEmojisList::-webkit-scrollbar {
width: 6px;
}
.wxEmojisList::-webkit-scrollbar-track {
background-color: #fff;
border-radius: 10px;
}
.wxEmojisList::-webkit-scrollbar-thumb {
background-color: #c1c1c1;
border-radius: 20px;
}
}
</style>
2.2 表情包数据
2.2.1 store->modules->wxEmojisData.js
const state = {
wxEmojisArr: [
'[微笑]', '[撇嘴]', '[色]', '[发呆]', '[得意]', '[流泪]', '[害羞]', '[闭嘴]',
'[睡]', '[大哭]', '[尴尬]', '[发怒]', '[调皮]', '[呲牙]', '[惊讶]', '[难过]',
'[囧]', '[抓狂]', '[吐]', '[偷笑]', '[愉快]', '[白眼]', '[傲慢]', '[困]',
'[惊恐]', '[憨笑]', '[悠闲]', '[咒骂]', '[疑问]', '[嘘]', '[晕]', '[衰]',
'[骷髅]', '[敲打]', '[再见]', '[擦汗]', '[抠鼻]', '[鼓掌]', '[坏笑]', '[右哼哼]',
'[鄙视]', '[委屈]', '[快哭了]', '[阴险]', '[亲亲]', '[可怜]', '[笑脸]', '[生病]',
'[脸红]', '[破涕为笑]', '[恐惧]', '[失望]', '[无语]', '[嘿哈]', '[捂脸]', '[奸笑]',
'[机智]', '[皱眉]', '[耶]', '[吃瓜]', '[加油]', '[汗]', '[天啊]', '[Emm]',
'[社会社会]', '[旺柴]', '[好的]', '[打脸]', '[哇]', '[翻白眼]', '[666]', '[让我看看]',
'[叹气]', '[苦涩]', '[裂开]', '[嘴唇]', '[爱心]', '[心碎]', '[拥抱]', '[强]',
'[弱]', '[握手]', '[胜利]', '[抱拳]', '[勾引]', '[拳头]', '[OK]', '[合十]',
'[啤酒]', '[咖啡]', '[蛋糕]', '[玫瑰]', '[凋谢]', '[菜刀]', '[炸弹]', '[便便]',
'[月亮]', '[太阳]', '[庆祝]', '[礼物]', '[红包]', '[發]', '[福]', '[烟花]',
'[猪头]', '[爆竹]', '[跳跳]', '[发抖]', '[转圈]'
]
}
export default {
namespaced: true,
state,
};
2.2.2 store->getters.js
const getters = {
wxEmojisData: state => state.wxEmojisData.wxEmojisArr
}
export default getters
2.2.4 store->index.js
import Vue from 'vue'
import Vuex from 'vuex'
import getters from './getters'
import wxEmojisData from './modules/wxEmojisData' //微信默认表情包数据
Vue.use(Vuex)
export default new Vuex.Store({
modules: {
wxEmojisData
},
getters,
})
2.3 聊天框js
2.3.1 表情包回显js
实现思路:
- 以背景(雪碧图)的形式插入图片,
- 设置插入表情包元素的dom标签的width、height值,控制雪碧图的background-position属性来回显对应的表情包
- 表情包的数据格式为数组:['[微笑]', '[撇嘴]'...],是为了提交的时候可以提交对应的数据
- 表情包的回显是通过遍历的形式,将数组的每一项('[微笑]')替换成dom元素,找出表情包的布局规则,动态设置background-position值
表情包回显js
import store from '@/store';
//表情包回显
export function wxEmojis(str) {
let iconArr = store.getters.wxEmojisData; //存放表情包的数组
iconArr.forEach((item, index) => {
if (str.indexOf(item) !== -1) {
let bgLeft = "", //横坐标
bgTop = "", //纵坐标
bhPosition = "0 0",
icoLen = 7, //表情包-行的个数
icoHeight = 36; //表情包的高度
if (index < 7) {
//第一列的表情包
bgLeft = -(24 * index + (16.8 * index)) + 'px';
bgTop = 0;
bhPosition = bgLeft + ' ' + bgTop;
} else if (index >= 7) {
let icoColumn = parseInt(index / 7);//表情包第几列
bgLeft = -(24 * (index - (icoColumn * icoLen)) + (16.8 * (index - (icoColumn * icoLen)))).toFixed(2) + 'px';
bgTop = -(icoColumn * icoHeight) + 'px';
bhPosition = bgLeft + ' ' + bgTop;
}
str = str.replaceAll(item, '<i class="wxEmojis" contenteditable="false" style="background-position:' + bhPosition + '" data-value=' + item + '></i>');
}
})
return str;
}
2.3.2 聊天框公共js
//发送聊天信息-js
export default {
trimHtml(str) {
/**
* 可编辑的div
* 1.换行是通过给内容插入<div></div>标签;提交的时候要把<div></div>替换成换行符;
* 2.空格是通过插入" ",提交的时候要转换成空格;
* 3.提交数据的时候将html标签去掉,只保留里面的纯文本内容;
*/
str = str.replaceAll("<div>", "\n");
str = str.replaceAll("</div>", " ");
str = str.replaceAll("<br>", " ");
str = str.replaceAll(" ", " ");
str = str.replace(/<\/?[^>]*>/g, "");
str = str.replace(/<[^>]*>/g, "");
return str;
},
// 是否展示 @
showAt() {
const node = this.getRangeNode(); // 获取节点
if (!node || node.nodeType !== Node.TEXT_NODE) return false;
const content = node.textContent || "";
const regx = /@([^@\s]*)$/;
const match = regx.exec(
content.slice(0, this.getCursorIndex())
);
return match && match.length === 2;
},
// @成员弹窗出现的位置
getRangeRect(groupUserCount) {
//console.log('@成员弹窗出现的位置-groupUserCount',groupUserCount)
const selection = window.getSelection();
const range = selection.getRangeAt(0); // 是用于管理选择范围的通用对象
const rect = range.getClientRects()[0]; // 择一些文本并将获得所选文本的范围
let LINE_HEIGHT = 180; //群成员占据的高度
//判断群成员的个数
if (groupUserCount < 4) {
LINE_HEIGHT = groupUserCount * 36;
} else {
LINE_HEIGHT = 180;
}
// console.log('rect', rect)
// console.log('rect.y',rect.y - LINE_HEIGHT)
return {
x: rect.x,
y: rect.y - LINE_HEIGHT,
};
},
// 获取 @ 用户
getAtUser() {
const content = this.getRangeNode().textContent || "";
const regx = /@([^@\s]*)$/;
const match = regx.exec(
content.slice(0, this.getCursorIndex())
);
if (match && match.length === 2) {
return match[1];
}
return undefined;
},
//选择表情包后在聊天框内插入html
insertHtmlAtCaret(html, chatBlurIndex) {
console.log("document.createRange()", document.createRange());
var sel, range;
if (window.getSelection) {
// IE9 and non-IE
sel = window.getSelection();
if (sel.getRangeAt && sel.rangeCount) {
if (!chatBlurIndex) {
range = sel.getRangeAt(0);
} else {
range = chatBlurIndex;
}
range.deleteContents();
// Range.createContextualFragment() would be useful here but is
// non-standard and not supported in all browsers (IE9, for one)
var el = document.createElement("div");
el.innerHTML = html;
var frag = document.createDocumentFragment(),
node,
lastNode;
while ((node = el.firstChild)) {
lastNode = frag.appendChild(node);
}
range.insertNode(frag);
// Preserve the selection
console.log("lastNode", lastNode);
if (lastNode) {
range = range.cloneRange();
range.setStartAfter(lastNode);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
}
}
//this.chatTextareaClick();
}
},
// 创建@成员标签
createAtButton(user) {
const atBtn = document.createElement("span");
atBtn.style.display = "inline-block";
//vertical-align: bottom;
atBtn.dataset.user = JSON.stringify(user);
atBtn.className = "at-button";
atBtn.contentEditable = "false";
atBtn.textContent = `@${user.nick_name} `;
return atBtn;
},
// 插入@标签
replaceAtUser(replaceHtml, that) {
const node = that.node;
console.log("replaceHtml", replaceHtml);
console.log("node", that.node);
if (node && replaceHtml) {
const content = node.textContent || "";
const endIndex = that.endIndex;
const preSlice = this.replaceString(
content.slice(0, endIndex),
""
);
const restSlice = content.slice(endIndex);
const parentNode = node.parentNode;
const nextNode = node.nextSibling;
const previousTextNode = new Text(preSlice);
const nextTextNode = new Text("\u200b" + restSlice); // 添加 0 宽字符
let atButton;
atButton = this.createAtButton(replaceHtml);
if (node) {
parentNode.removeChild(node);
}
// 插在文本框中
if (nextNode) {
parentNode.insertBefore(previousTextNode, nextNode);
parentNode.insertBefore(atButton, nextNode);
parentNode.insertBefore(nextTextNode, nextNode);
} else {
parentNode.appendChild(previousTextNode);
parentNode.appendChild(atButton);
parentNode.appendChild(nextTextNode);
}
// 重置光标的位置
const range = new Range();
const selection = window.getSelection();
range.setStart(nextTextNode, 0);
range.setEnd(nextTextNode, 0);
selection.removeAllRanges();
selection.addRange(range);
}
},
// 获取光标位置
getCursorIndex() {
const selection = window.getSelection();
return selection.focusOffset; // 选择开始处 focusNode 的偏移量
},
// 获取节点
getRangeNode() {
const selection = window.getSelection();
return selection.focusNode; // 选择的结束节点
},
//将@替换
replaceString(raw, replacer) {
return raw.replace(/@([^@\s]*)$/, replacer);
}
}