在项目中,如果有聊天页面或者待办页面,往往涉及到艾特(@)功能,外面的插件往往不然自定义来满足产品的需求,这时我们需要自定义mention组件来完成功能!!!
下图涉及到了几个功能,一个@功能,插入表情功能,OSS文件前端直传,发送等?,本人使用的vue页面写的,该文章只讲mention功能与表情插入功能,其它的在其他文章中讲述,具体看下文
?样式部分
<template>
<div id="comment-out-box">
<div class="footer-comment-send-box">
<div id="comment-input-data" contenteditable="true" @input="handleInput" @keydown="handleKeydown" @keyup="handleKeyup" @click.stop="handleKeyup"></div>
<div class="footer-power-box">
<div class="comment-emote-btn" @click.stop="clickShowDrawer(1)"></div>
<div class="comment-file-btn" v-if="!showSendBtn" @click.stop="clickShowDrawer(2)" :class="{'comment-file-btn_active':showDrawerDown && showEmoteOrFile === 2}"></div>
<div class="comment-send-enter" v-else @click.self="enterSendCommentOrReply(1)">发 送</div>
</div>
<div class="mention-box" v-if="isShowBox" :style="{'bottom':mentionBottom + 'px', 'left':mentionLeft + 'px'}">
<div>所有人({{ filterUserList.length }})</div>
<div class="user-box">
<div :data-id="item.userId" v-for="item in filterUserList" :key="item.userId" @click.stop="selectUser(item)">
<img class="avatarImg" :src="item.avatar" alt=""/>
<span v-html="searchHeightLight(item,selectionName)"></span>
</div>
</div>
</div>
</div>
<!-- 下拉面板 表情+文件 -->
<div class="footer-power-drawer" v-if="showDrawerDown">
<div class="emote-box" v-if="showEmoteOrFile === 1">
<span class="emote-item" v-for="emote in emoteData" :key="emote" @click="insertEmoji">{{emote}}</span>
</div>
// 上传文件功能...
</div>
</div>
</template>
<style lang="scss" scoped>
.mention-box {
position: absolute;
min-width: 120px;
max-width: 200px;
padding: 10px;
background: #fff;
border-radius: 5px;
border: 1px solid gainsboro;
box-sizing: border-box;
box-shadow: 0 0 5px rgba(0,0,0,0.1);
max-height: 245px;
z-index: 9999999;
font-size: 12px;
& > div:first-child {
font-weight: bold;
padding: 0 0 5px 0;
}
.user-box {
padding: 0;
margin: 0;
overflow: hidden;
overflow-y: auto;
max-height: 200px;
&::-webkit-scrollbar {
display: none;
}
div {
padding: 5px 10px;
cursor: pointer;
display: flex;
align-items: center;
&:hover {
background: #46A8FF;
color: #fff;
}
.avatarImg {
width: 20px;
height: 20px;
border-radius: 50%;
margin-right: 5px;
vertical-align: sub;
}
span {
width: 86%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
display: inline-block;
}
}
}
</style>
?在App.vue中设置mention的样式,可能该条信息在其他页面也要展示,该条信息的记录弹窗页面,所以,在全局设样式比较好点,具体看个人
/**
* App.vue中
* 网址、@功能
**/
.mention-link, .mention-at {
color: #46A8FF;
margin-right: 5px;
}
表情库:
// emote.js中
export var emoteData = [
...
"👹",
"👺",
"🤡",
"💩",
"👻",
"💀",
"?",
"👽",
"👾",
"🤖",
"🎃",
"😺",
"😸",
"😹",
"😻",
"😼",
"😽",
"🙀",
"😿",
"😾",
...
]
逻辑部分:
export default {
name: "tagDetail",
components: { VuePdf },
data() {
return {
ignoreUserDataList:[], // @功能获取存储用户的数据
ignoreUserIdList:[], // @功能存储用户的选中id集合
filterUserList: [], // @列表
isShowBox: false, // 是否显示@列表
mentionBottom:0, // @列表位置 bottom
mentionLeft:20, // @列表位置 left
savedSelectionMention: null, // 保存选中位置
savedSelectionRange: null, // 保存选中位置
selectionName: false, // @列表筛选用户
isRecording: false, // 是否正在记录
// ... 其他功能的变量 ...
}
},
computed: {
rangeLength() {
return this.savedSelectionRange.length > 0 && this.savedSelectionRange[0].startOffset - this.savedSelectionMention[0].startOffset
}
},
methods:{
/**
* @函数描述: 输入框输入变化时的回调函数
**/
handleInput(event) {
const commentBox = document.querySelector('.comment-input-data');
// 显示隐藏评论按钮
if(commentBox.innerHTML.length > 0){
this.showSendBtn = true
if(this.showDrawerDown && this.showEmoteOrFile === 2){
this.showDrawerDown = false
}
}else {
this.showSendBtn = false
}
if (this.isRecording) {
this.savedSelectionRange = this.saveSelection();
}
// 获取当前选区的范围对象
const selection = window.getSelection();
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
// 折叠range以确保它的start和end在相同位置
range.collapse(true);
// 如果不在编辑div的起始位置,创建一个新的range来查找前一个字符
if (range.startOffset > 0) {
// 复制当前选区的范围
const rangeStart = range.cloneRange();
// 将这个新range的起始点向前移动一个字符
rangeStart.setStart(rangeStart.startContainer, range.startOffset - 1);
// 选择这个字符
rangeStart.setEnd(rangeStart.startContainer, range.startOffset);
// 获取这个字符并检查它是否是 '@'
const charBeforeCursor = rangeStart.toString();
if (charBeforeCursor === '@') {
this.ignoreUserIdList = this.mentionCreateUserIdList(commentBox.innerHTML)
this.savedSelectionMention = this.saveSelection();
const rect = range.getClientRects()[0];
this.mentionBottom = rect.top - event.target.getBoundingClientRect().top + 30
this.mentionLeft = rect.left - event.target.getBoundingClientRect().left + 20
if(event.target.clientWidth - this.mentionLeft < 85){
this.mentionLeft = this.mentionLeft - 200
}
this.isRecording = true
let data = {
ignoreUserIdList: this.ignoreUserIdList,
tagId: this.tagId,
}
get_interaction_mentioning(data).then(res => {
this.ignoreUserDataList = res.data.data
this.filterUserList = res.data.data
this.isShowBox = true
})
}
}
}
},
/**
* @函数描述: 键盘输入时的回调函数 - 【弹起】
**/
handleKeyup() {
const commentBox = document.querySelector('.comment-input-data');
this.savedSelectionRange = this.saveSelection();
if(commentBox.innerHTML === ''){
this.isRecording = false
}
if (this.isRecording) {
if (this.rangeLength < 0) {
this.isShowBox = false
} else {
this.isShowBox = true
this.filterSelectionName()
}
}
},
/**
* @函数描述: 键盘输入时的回调函数 - 【按下】
* @param: {object} event
**/
handleKeydown(event) {
const BACKSPACE_KEY = 'Backspace';
const selection = window.getSelection();
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
// 如果按下的是返回键
if (event.key === BACKSPACE_KEY) {
// 检查并且消除选区
if (!range.collapsed) {
// 用户有一个激活的选区,可能会删除多个字符
return;
}
// 如果不在内容的开头位置
if (range.startOffset > 0) {
// 创建一个新的range来查找要删除的字符
const rangeToDelete = range.cloneRange();
rangeToDelete.setStart(rangeToDelete.startContainer, range.startOffset - 1);
rangeToDelete.setEnd(rangeToDelete.startContainer, range.startOffset);
const charBeforeCursor = rangeToDelete.toString();
if (charBeforeCursor === '@') {
this.isShowBox = false
this.savedSelectionMention = null;
this.savedSelectionRange = null;
this.isRecording = false
}
}else {
this.isRecording = false
this.savedSelectionMention = null;
}
this.showSendBtn = false;
}
}
if(event.keyCode === 13) {
//回车执行查询
event.preventDefault()
this.enterSendCommentOrReply(1)
}
},
/**
* @函数描述: 过滤输入框内的姓名
**/
filterSelectionName() {
const commentBox = document.querySelector('.comment-input-data');
this.selectionName = commentBox.innerHTML.replace(/<[^>]*>[^<]*<\/[^>]*>/g, function (match) {
return match.replace(/[^]*/g, ''); // 使用正则表达式替换标签的内容
}).split('@')[1].substr(0, this.rangeLength);
this.filterUserList = this.ignoreUserDataList.filter(item => item.userName.indexOf(this.selectionName) > -1)
},
/**
* 选择用户
* @param row
*/
selectUser(row) {
const span = document.createElement('span');
span.className = 'mention-at';
span.id = row.userId;
span.textContent = `@${row.userName}`;
span.setAttribute('contenteditable', 'false');
const commentBox = document.querySelector('.comment-input-data');
// 重新聚焦到评论框并恢复之前的选择
commentBox.focus();
const selection = window.getSelection();
if (this.savedSelectionMention && this.savedSelectionMention.length > 0) {
selection.removeAllRanges();
selection.addRange(this.savedSelectionMention[0]);
}
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
range.setStart(range.startContainer, range.startOffset - 1);
range.setEnd(range.startContainer, range.endOffset + this.rangeLength);
range.deleteContents(); // 删除 "@"
const newRange = document.createRange();
range.insertNode(span);
newRange.setStartAfter(span);
newRange.collapse(true);
// APPLY THE NEW RANGE
selection.removeAllRanges();
selection.addRange(newRange);
}
this.isRecording = false
this.isShowBox = false;
// 清除savedSelection状态,因为已经不再需要了
this.savedSelectionMention = null;
// this.savedSelectionRange = null;
this.savedSelectionRange = this.saveSelection()
},
/**
* 这里是之前提到的saveSelection函数的示例实现
* @returns {*[]}
*/
saveSelection() {
const ranges = [];
const selection = window.getSelection();
for (let i = 0; i < selection.rangeCount; i++) {
ranges.push(selection.getRangeAt(i));
}
return ranges;
},
/**
* @函数描述: 模糊搜索下拉框精确匹配文字高亮
* @param {object} content 下拉项的内容
*/
searchHeightLight(content, title) {
let reg = ''
let dataToReplace = `<span style="color: #ff4d51">${title}</span>`
let roleName = `(<span>${content.roleName}</span>)`
if (content.userName.indexOf(title) !== -1) {
reg = content.userName.replace(title, dataToReplace) + roleName;
} else {
reg = content.userName + roleName;
}
return reg
},
/**
* @函数描述: 点击表情添加到输入框
* @param: {Object} event 点击的对象
**/
insertEmoji(emoji) {
const commentBox = document.querySelector('.comment-input-data');
// 重新聚焦到评论框并恢复之前的选择
commentBox.focus();
const selection = window.getSelection();
if (this.savedSelectionRange && this.savedSelectionRange.length > 0) {
selection.removeAllRanges();
selection.addRange(this.savedSelectionRange[0]);
}
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
range.setStart(range.startContainer, range.startOffset);
range.setEnd(range.startContainer, range.endOffset);
range.deleteContents();
const newRange = document.createRange();
const emojiNode = document.createTextNode(emoji)
range.insertNode(emojiNode);
newRange.setStartAfter(emojiNode);
newRange.collapse(true);
// APPLY THE NEW RANGE
selection.removeAllRanges();
selection.addRange(newRange);
this.savedSelectionRange = this.saveSelection()
}
this.showSendBtn = true;
},
/**
* @函数描述: 获取@提及功能的用户id集合
* @param: {string} text
**/
mentionCreateUserIdList(string) {
const parser = new DOMParser();
const doc = parser.parseFromString(string, "text/html");
const spanElements = doc.querySelectorAll("span");
const idValues = Array.from(spanElements).map(span => span.id);
const filterValues = idValues.filter(item => item !== "")
return filterValues;
},
}
}
?功能是简单,主要是光标的处理问题比较绕
结果:
?
代码持续优化中,如果有更好的方法,欢迎评论区交流!