从零打造前沿Web聊天组件:@成员和表情包设计实现

在之前的文章当中,从需求分析、技术选型到具体实现,逐步完成了聊天室的基础功能,包括动态组件切换、消息展示和输入框交互。

上一篇:从零打造前沿Web聊天组件:从设计到交互作者现在正制作一款聊天室(青春版),本文主要对于Vue3聊天组件进行着重开发讲解 - 掘金

已完成:

  • 界面UI:当前效果展示了聊天室列表的基本UI,实现了动态切换聊天室的功能,点击左侧聊天室可切换右侧内容。
  • 动态组件模式 :选择动态组件模式(groupChatprivateChat)以实现类型隔离、性能优化和可扩展性。
  • 高复用性设计:通过组合式API和插槽组件分离通用逻辑与特殊逻辑。
  • 组件创建 :创建了groupChat.vueprivateChat.vueBaseMessage.vue三个组件文件
  • 组件交互
    • 使用模拟数据展示聊天记录和用户头像。
    • 时间分割线:根据时间维度(日期变化、设定间隔)和活跃度维度判断是否显示分割线,并格式化时间显示。
    • 消息列表布局和样式已实现。
  • 消息输入框
    • 高度调节:通过CSS和JS实现输入框高度的拖动调整(100px-200px)。
    • 功能扩展区:基础功能包括表情和聊天记录按钮,预留插槽供后续扩展。
    • 可编辑DIV输入框
      • 支持Enter发送消息,Shift+Enter换行。
      • 处理粘贴内容,纯文本去格式,图片自动缩小。

对于消息输入框 中,还需要添加 @成员提及 功能和 插入表情

@成员

成员提及功能就是指 @功能 ,此功能一般只在群聊中可用,所以需要通过props进行开放限制

js 复制代码
chatType: {
	type: String,
	default: 'group'
}

键盘交互系统

按键 行为 方法
↑/↓ 导航成员列表 navigate()
←/→/Backspace 关闭弹窗 close()
Enter 插入选中成员或发送消息 selectCurrentMember()

最终效果

这里的成员提及功能较为复杂,所以我将@成员列表提示弹窗独立封装成一个组件mention.vue,此组件作为@成员功能的专用组件,与输入框紧密配合,需要跟随输入光标位置。

弹窗列表组件

父组件调用:

html 复制代码
<mention ref="mentionComp" :members="mentionList" @select-member="insertMemberName" ></mention>

用于显示成员提及列表的弹出式组件,主要功能分析如下:

  1. 模板部分
    • 使用 v-if 控制显示/隐藏
    • 通过 :style 动态设置弹出位置
    • 遍历 members 列表显示成员信息
    • 包含头像和名称显示区域
  2. 主要功能
    • 显示/隐藏控制:通过 showMentionList 响应式变量,外部可通过调用openclose方法进行列表显隐控制
    • 位置控制:通过 position 响应式对象设置成员列表出现的位置
    • 成员选择: - 点击选择: selectMember 方法 - 键盘导航: navigate 方法(上下箭头) - 回车确认: selectCurrentMember 方法
  3. 与父组件交互
    • 通过 defineEmits 定义 select-member 事件,子组件调用外部父组件方法
    • 通过 defineExpose 暴露方法供父组件调用
    • 通过 defineProps 接收成员列表数据
  4. 样式部分
    • 绝对定位的弹出框样式
    • 成员列表项样式和选中状态
    • 最大高度和滚动条控制

说实话制作了弹窗组件后,只是完成一小部分的功能而已,更多的内容在外部输入框中处理

核心问题

  1. 判断输入 @ 时调用弹窗列表,并获取光标的位置设置弹窗
  2. 弹窗支持上下键移动选择用户,回车和鼠标点击选择用户时,选区转移处理
  3. 如何将 @用户名 判断为一个整体,而不是普通文本
  4. 保证删除时能够整体删除,不是逐字符删除

实现方案

1. 判断@调用弹窗

创建一个handleInput 输入方法,将其绑定到div消息输入框上

html 复制代码
@input="handleInput"

此方法主要用来检测@符号输入并触发成员列表显示,并且支持成员名称的模糊匹配

  • 使用 atIndexFlag 标记@输入状态
  • 通过 window.getSelection() 获取当前光标位置
  • 支持中文输入法特殊字符处理
  • 仅在群聊模式下生效( chatType === 'group' )

方法代码

js 复制代码
let atIndexFlag = false;
const handleInput = () => {
	// 监听@键触发
	const selection = window.getSelection();
	if (props.chatType === 'group') {
		const range = selection.getRangeAt(0);
		const text = range.startContainer.textContent;
		const cursorPos = range.startOffset;
		// 检查是否输入了@符号
		if (text[cursorPos - 1] === '@') {
			// 触发显示用户列表
			showMentionList();
			atIndexFlag = true;
		} else if (atIndexFlag) {
			// 匹配@符号后面和光标前的内容是否与mentionList中的name相同
			let atIndex = text.lastIndexOf('@', cursorPos - 2);
			const textBeforeCursor = text.substring(atIndex + 1, cursorPos);
			// 主要处理中文输入
			if (textBeforeCursor.includes(' ')) {
				mentionComp.value.close();
				return ;
			}
			// 匹配mentionList中的name
			showMentionList(textBeforeCursor);
		}
	}
}

这里调用的showMentionList方法核心功能是显示用户列表,主要功能包括:

  1. 获取选区信息 :通过 window.getSelection() 获取当前光标位置和选区范围
  2. 克隆选区 :使用 cloneSelection() 保存当前选区状态,以便后续恢复
  3. 过滤成员列表 :调用 useHandleUserInfo 根据输入内容过滤可提及的成员
  4. 计算弹窗位置 :根据成员列表数量和光标位置计算弹窗显示位置
  5. 调用子组件 :通过 mentionComp.value.open() 打开成员列表弹窗

这样就能够根据输入框输入的@,弹出相应列表弹窗,并且位置在@后

2. 控制键盘事件

因为出现弹窗列表后,需要根据上下键移动选择用户,回车和鼠标点击选择用户时,选区转移处理

这里就需要添加 handleKeyDownhandleEnter 事件

handleKeyDown:仅在 @ 提及功能激活时处理特定按键,处理上下箭头键用于导航成员列表,处理左右箭头、退格和删除键用于关闭成员列表

handleEnter:阻止默认回车行为,判断当前是否显示成员列表弹窗 * 如果显示:获取当前选中成员并插入其名字 * 如果未显示:触发发送消息功能

方法代码

js 复制代码
// 在键盘事件中调用
const handleKeyDown = (e) => {
	if (!atIndexFlag) return;
	if (e.key === 'ArrowUp') {
		e.preventDefault();
		mentionComp.value.navigate('up');
	} else if (e.key === 'ArrowDown') {
		e.preventDefault();
		mentionComp.value.navigate('down');
	} else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Backspace' || e.key === 'Delete') {
		mentionComp.value.close();
	}
};
// 输入框回车事件处理
const handleEnter = (e) => {
	e.preventDefault();
	// 判断@成员列表弹窗是否显示
	if (mentionComp.value.showMentionList) {
		// 调用子组件的 selectCurrentMember 方法
		let currentMember = mentionComp.value.selectCurrentMember();
		// 如果有选中的成员,插入到光标位置
		if (currentMember) {
			insertMemberName(currentMember.name);
		}
	} else {    
		// 调用发送消息方法
		sendMessage();
	}
}

3. 列表选中用户,输入框添加@用户名

这里最重要的地方来了,如何让列表中选中的用户出现在输入框,并与普通文本作区别。

关键点

  • 使用 span 包裹,使其在 DOM 中成为一个独立节点
  • 设置 contenteditable="false" 防止部分浏览器误操作
  • 克隆当前选区和光标位置,恢复选区和光标位置

创建一个插入用户名方法insertMemberName,主要功能为:

  1. 选区恢复 :如果存在保存的选区(clonedRange )且flagfalse,则恢复之前保存的选区位置
  2. 获取当前选区 :通过**window.getSelection()**获取当前选区信息
  3. 查找 @ 符号位置 :在光标前查找最后一个 @ 符号的位置
  4. 删除@符号及后续内容 :设置选区范围从 @ 符号到光标位置,并删除这部分内容
  5. 创建并插入成员名字节点 :
    • 创建一个带有特定样式的<span>元素
    • 设置元素内容为 @ 成员名字
    • 将元素插入到选区位置
  6. 移动光标 :创建新选区并将光标移动到插入的成员名字后面

insertMemberName代码:

js 复制代码
const insertMemberName = (memberName, flag = true) => {
	if (clonedRange && !flag) {
		restoreSelection(clonedRange);
	}
	const selection = window.getSelection();
	const range = selection.getRangeAt(0);
	const text = range.startContainer.textContent;
	const cursorPos = range.startOffset;
	// 查找最后一个@符号的位置,进行删除
	const atIndex = text.lastIndexOf('@', cursorPos - 1);
	if (atIndex !== -1) {
		// 设置选区范围从@符号到光标位置
		range.setStart(range.startContainer, atIndex);
		range.setEnd(range.startContainer, cursorPos);
		// 删除选区内容
		range.deleteContents();
	}

	// 插入@名字
	const mentionNode = document.createElement('span');
	mentionNode.className = 'insert-mention';
	mentionNode.contentEditable = 'false';
	mentionNode.style.color = '#007bff';
	mentionNode.textContent = `@${memberName}`;
	
	// 更新输入框内容
	range.insertNode(mentionNode);
	// 移动光标到名字后面
	const newRange = document.createRange();
	// 移动光标到 mention 后面
	newRange.setStartAfter(mentionNode);
	selection.removeAllRanges();
	selection.addRange(newRange);
}

点击用户列表中的用户时会将选区从输入框中移出,这里需要定义两个方法用于恢复选区和光标

  1. cloneSelection 方法 :
    • 获取当前选区对象
    • 克隆选区范围
    • 返回克隆后的选区对象
    • 主要用于保存当前光标位置和选区状态
  2. restoreSelection 方法 :
    • 接收一个克隆的选区对象作为参数
    • 清除当前所有选区
    • 将克隆的选区对象添加到当前选区中
    • 主要用于恢复之前保存的光标位置和选区状态

这两个方法配合使用,在需要临时保存和恢复选区状态的场景下非常有用

方法代码

js 复制代码
/** 
* 克隆当前选区和光标位置
* @returns {Range} 克隆后的选区对象
*/
const cloneSelection = () => {
    const selection = window.getSelection();
    const range = selection.getRangeAt(0);
    const clonedRange = range.cloneRange();
    return clonedRange;
}
/*  
* 恢复之前的选区和光标位置
* @param {Range} clonedRange - 之前克隆的选区对象
*/
const restoreSelection = (clonedRange) => {
    const selection = window.getSelection();
    selection.removeAllRanges();
    selection.addRange(clonedRange);
}

目前通过DOM操作和事件管理,实现聊天场景下的@成员提及功能,既保证用户体验流畅性,又为后续扩展留有设计空间。


表情包

表情包插入 的难度相比于 @成员 功能就低了很多,创建一个表情包的组件emojiPlane.vue

因为是简易版聊天室,表情包就不设计复杂的功能了,只有最基础的emoji表情,这样做的好处是无图片依赖,可以直接使用Unicode字符。

表情数据 :(使用 flatMap 将所有表情展开为一维数组 allEmojis

使用绝对定位的弹窗展示表情面板,因为触发可能比较频繁,所以通过 v-show 控制显示/隐藏,每个表情项都有悬停效果和点击事件。

功能

  • selectEmoji 方法触发 select-emoji 事件并关闭面板
  • 提供 openclose 方法供父组件调用
  • 点击面板外部区域自动关闭

选择表情,添加到输入框

选区处理

  • 如果存在保存的选区( clonedRange ),则恢复选区
  • 否则聚焦到输入框并创建新选区 光标定位
  • 插入后移动光标到表情后面
  • 确保后续输入从正确位置开始

方法代码

js 复制代码
/**
 * 插入表情包方法
 * @param {*} emoji 表情对象,包含 symbol 和 name 属性
 */
const insertEmoji = (emoji) => {
	// 根据记录的光标位置插入表情
	if (clonedRange) {
		restoreSelection(clonedRange);
		
	} else {
		// 如果没有选区,则聚焦到输入框并创建新选区
		editableDiv.value.focus();
	}
	const selection = window.getSelection();
	const range = selection.getRangeAt(0);
	const aInsert = document.createElement('span');
	aInsert.innerHTML = emoji.symbol;
	aInsert.alt = emoji.name;
	aInsert.className = 'insert-emoji';
	range.insertNode(aInsert);
	// 移动光标到表情后面
	const newRange = document.createRange();
	newRange.setStartAfter(aInsert);
	selection.removeAllRanges();
	selection.addRange(newRange);
}

表情包最终效果


两个弹窗组件的具体代码下边就不具体展示了,只放一下script脚本代码

mention.vue组件代码:

html 复制代码
<!-- 成员列表组件 -->
<script setup>
    import { ref, reactive, onMounted } from "vue";
    const emit = defineEmits(['select-member']);
    const props = defineProps({
        members: {
            type: Array,
            required: true
        }
    })
    // 是否显示
    const showMentionList = ref(false);
    // 显示位置
    const position = reactive({ x: 0, y: 0 });
    // 默认选中的成员
    let selectedMember = ref("");
    // 提供父组件调用的方法
    defineExpose({
        // 打开弹窗
        open:(pos, name)=> {
            position.x = pos.x;
            position.y = pos.y;
            showMentionList.value = true;
            selectedMember.value = name;
        },
        // 关闭弹窗
        close:()=> {
            showMentionList.value = false; 
        },
        // 选中成员,键盘导航逻辑
        navigate: (direction)=> {
            if (!showMentionList.value) return;
            const currentIndex = props.members.findIndex(m => m.name === selectedMember.value);
            
            if (direction == "up" && currentIndex > 0) {
                selectedMember.value = props.members[currentIndex - 1].name;
            } else if (direction == "down" && currentIndex < props.members.length - 1) {
                selectedMember.value = props.members[currentIndex + 1].name;
            }
        },
        // 回车键选择当前成员
        selectCurrentMember: ()=> {
            if (!showMentionList.value) return null;
            const currentMember = props.members.find(m => m.name === selectedMember.value);
            showMentionList.value = false;
            return currentMember;
        },
        showMentionList,
        selectedMember
    })

    // 选择成员
    const selectMember = (name)=> {
        showMentionList.value = false; 
        emit('select-member', name, false);
    }

    // 点击其他地方关闭
    onMounted(()=>{
        document.addEventListener('click', (e)=>{
            if (!e.target.closest('.mention-popover')) {
                showMentionList.value = false;
            }
        })
    })

</script>

emojiPlane.vue组件代码:

html 复制代码
<script setup >
    import { ref, reactive, onMounted } from "vue";
    // 表情数据
    const emojis = [
        {
            name: '表情',
            emojis: [
                { symbol: '😀', name: '笑脸' },
                { symbol: '😂', name: '笑哭' },
                ...
            ]
        },
        ...
    ]
    // 将所有表情展开成一维数组
    const allEmojis = emojis.flatMap(emoji => emoji.emojis);
    
    // 显示位置
    const position = reactive({ x: 0, y: 0 });
    // 是否显示
    const showEmojiPanel = ref(false);
    const emit = defineEmits(['select-emoji']);
    // 选择表情
    const selectEmoji = (emoji)=> {
        emit('select-emoji', emoji);
        showEmojiPanel.value = false;
    }

    // 提供父组件调用的方法
    defineExpose({
        open:(pos)=> {
            position.x = pos.x;
            position.y = pos.y;
            showEmojiPanel.value = true;
        },
        close:()=> {
            showEmojiPanel.value = false;
        },
        showEmojiPanel
    })

    onMounted(()=>{ 
        document.addEventListener('click', (e)=>{
            if (!e.target.closest('.emoji-panel') && !e.target.closest('.fun-emoji')) {
                showEmojiPanel.value = false;
            }
        })
    })
</script>
相关推荐
_r0bin_2 小时前
前端面试准备-7
开发语言·前端·javascript·fetch·跨域·class
IT瘾君2 小时前
JavaWeb:前端工程化-Vue
前端·javascript·vue.js
zhang98800002 小时前
JavaScript 核心原理深度解析-不停留于表面的VUE等的使用!
开发语言·javascript·vue.js
potender2 小时前
前端框架Vue
前端·vue.js·前端框架
站在风口的猪11083 小时前
《前端面试题:CSS预处理器(Sass、Less等)》
前端·css·html·less·css3·sass·html5
程序员的世界你不懂3 小时前
(9)-Fiddler抓包-Fiddler如何设置捕获Https会话
前端·https·fiddler
MoFe13 小时前
【.net core】天地图坐标转换为高德地图坐标(WGS84 坐标转 GCJ02 坐标)
java·前端·.netcore
去旅行、在路上4 小时前
chrome使用手机调试触屏web
前端·chrome
Aphasia3114 小时前
模式验证库——zod
前端·react.js
lexiangqicheng5 小时前
es6+和css3新增的特性有哪些
前端·es6·css3