之前做项目实现聊天功能,有几个功能点我觉得挺复杂的。今天我来说一下,我是如何实现图片小表情在输入框中显示,发送给后端时只发送一个含义字符串如:[emoji],然后正常回显在页面上。
此demo使用vue3
源码已上传:源码地址
实现效果图:
输入自定义表情发送并回显
声明:这只是个demo,不涉及与后端交互,不过会在该交互的地方标记,如需实际应用于项目,请根据实际情况进行改造完善!
父组件定义及逻辑实现
父组件dom定义如下,其中,输入框需要使用开启contenteditable
的div,不能使用input或者textarea。chatMsgEl为子组件,用来回显我们发送的消息结果。
<div id="app">
<div class="msg-box">
<!-- 消息输入框 -->
<div class="msg-input" ref="msgInput" contenteditable @blur="getAfterBlurIndex" @keydown.enter.prevent="sendMsg"></div>
<!-- 表情列表 -->
<div class="emoji-list">
<p>表情列表</p>
<img v-for="(emoji, key) in emojiList" :key="key" :src="emoji" @click="selectEmoji(key)" />
</div>
<button @click="sendMsg">发送</button>
</div>
<p>数据本体:{{ chatMsgRecord }}</p>
<!-- 消息回显框 -->
<chatMsgEl :msg="chatMsgRecord" :emojiList="emojiList"></chatMsgEl>
</div>
定义表情包列表,我这里只有一个本地图片,根据这个格式本地添加或者后端返回都行
const emojiList = {
"[vueLogo]": require("./assets/logo.png"),
};
然后定义几个变量,用来后续操作
let msgInput = ref(); // 输入框ref绑定
let chatMsgRecord = ref(""); // 发送的消息体
let focusNode = reactive({}); // 存储光标聚焦节点
let focusOffset = ref(0); // 存储光标聚焦偏移量
let chatInputOffset = reactive({}); // 存储光标聚焦的元素
想通过点击图片插入到输入框光标对应位置,最重要的就是在输入框失焦时获取到失焦前的光标位置,函数如下,具体不展开讲,不明白的话可以去搜一下关于getSelection的知识。
// 聊天输入框失焦时获取失焦前的光标位置
function getAfterBlurIndex() {
if (window.getSelection) {
let sel = window.getSelection();
if (sel.getRangeAt && sel.rangeCount) {
focusNode = sel.focusNode;
focusOffset.value = sel.focusOffset;
chatInputOffset = sel.getRangeAt(0);
console.log("focusNode:", focusNode);
console.log("focusOffset:", focusOffset.value);
console.log("chatInputOffset:", chatInputOffset);
}
}
}
然后我们点击表情添加到输入框中,这个步骤分为:先获取输入框焦点(如果输入框从来都没有获取过焦点的话),然后创建图片标签追加到输入框div的子节点中(图片标签中的alt是后面发送时需要取出的表情字符串定义),最后输入框聚焦并将光标后移
// 获取输入框选取
function getInputSelection() {
let sel = window.getSelection();
// 插入内容之前输入框是否已经聚焦获得选区
if (
!chatInputOffset.endContainer ||
(chatInputOffset.endContainer.className != "msg-input" &&
chatInputOffset.endContainer.parentNode.className != "msg-input" &&
chatInputOffset.endContainer.parentNode.parentNode.className != "msg-input")
) {
chatInputOffset = document.createRange();
//用于设置 Range,使其包含一个 Node的内容。
chatInputOffset.selectNodeContents(document.querySelector(".msg-input"));
//将包含着的这段内容的光标设置到最后去,true 折叠到 Range 的 start 节点,false 折叠到 end 节点。如果省略,则默认为 false .
chatInputOffset.collapse(false);
sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(chatInputOffset);
}
}
// 点击选择表情
function selectEmoji(emoji) {
getInputSelection(); // 先获得一下输入框焦点
// 创建图片元素,回显到输入框内
const img = `<img src="${emojiList[emoji]}" alt='${emoji}' class="emoji" style="width: 36px;height: 36px;object-fit: contain" >`;
chatInputOffset.collapse(false); //光标移至最后
// 创建节点并插入
const node = chatInputOffset.createContextualFragment(img);
let c = node.lastChild;
chatInputOffset.insertNode(node);
if (c) {
chatInputOffset.setEndAfter(c);
chatInputOffset.setStartAfter(c);
}
let j = window.getSelection();
j.removeAllRanges();
j.addRange(chatInputOffset);
}
这个时候就可以发送了,发送的步骤为:先清空发送的上一条消息,然后循环输入框子节点,查找追加进去的图片标签,取出标签上的alt赋值给chatMsgRecord变量,如果是普通文本消息就直接追加赋值。最后清空输入框中的内容即可
function sendMsg() {
console.log("msgInput:", msgInput);
chatMsgRecord.value = '' // 先清空一下旧消息
msgInput.value.childNodes.forEach((element) => {
console.log(element);
// 如果是emoji表情图片的话,则转义
if (element.nodeName === "IMG" && element.className === "emoji") {
chatMsgRecord.value += element.alt;
} else {
chatMsgRecord.value += element.wholeText;
}
});
// 清空输入框中的内容
msgInput.value.innerHTML = "";
msgInput.value.innerText = "";
//在这里使用websocket把数据chatMsgRecord发给后端
// socket.send({msg:chatMsgRecord})
}
子组件定义及逻辑实现
子组件这边的话就比较简单了,先定义好dom结构,下面讲为什么循环的消息变量需要使用split(/(<[^>]+>)/g)分割
<template v-for="text in chatMsg.split(/(<[^>]+>)/g)">
<template v-if="!text.startsWith('<emoji')">{{ text }}</template>
<img v-else :src="imgSrc(text)[1]" class="emoji" />
</template>```
拿到消息体,父组件把表情包定义传递给子组件,然后循环表情包列表,通过replace方法匹配到相对应的表情赋值给消息结果。假设此时的消息体为:文本[emoji]文本,则消息结果为:文本<emoji src=“http://xxx”>文本。
const props = defineProps(["msg", "emojiList"]);
// 注意:msg为后端返回给你的消息内容
const { msg, emojiList } = toRefs(props);
let chatMsg = ref(msg.value)
watch(
msg,
() => {
// 核心语句
for (const key in emojiList.value) {
const reg = new RegExp("(\\" + key + ")", "g");
chatMsg.value = msg.value.replace(reg, `<emoji src="${emojiList.value[key]}">`);
}
},
{ immediate: true }
);
这个时候来讲一下为什么上面循环的消息变量要使用split(/(<[^>]+>)/g)了,因为此时的消息为“文本<emoji src=“http://xxx”>文本”,通过分割就会变为[‘文本’,‘<emoji src=“http://xxx”>’,'文本],这样才能正确循环渲染。
在dom定义中,img的src使用的imgSrc函数,就是取出这个<emoji>标签里的src路径用的
function imgSrc(txt) {
return txt.match(/<emoji.*?src="(.*?)".*?>/);
}
这样就实现了输入回显、发送转义、结果回显功能了。
之前也试过直接把匹配到的表情包变成img标签,通过v-html直接回显的,但是这样做会有XSS风险!
后面我会更新如何实现@功能、复制图片到输入框中并发送,以及撤回等功能。