前言
一大早,又是美好的一天!
我
产品需求
给我实现@功能
我
简简单单,包在我身上!
几天之后
产品需求: 你好了没有?
我
构思
初次思考
作为小白的我,初入职场,可谓是艺低人胆大,这活接手之后,我的想法就是:这不就是键盘监听输入@符号,跳出弹框,可以选择@的人员,之后再把数据渲染到页面上吗?@之后的文字再用颜色整一下不就好了吗?
二次构思
后来才发现,踩了一大把的坑,在写@功能的时候,还是需要注意一下需求滴
-
输入@ 的时候,需要弹出人员选择器
-
人员选择器需要跟随在光标的位置出现
-
选择时 @的用户标签要插入当前的光标位置中
-
生成@的用户标签的规则是:高亮、携带用户ID、一键删除信息、且不可以编辑。
-
用户点击生成的标签或移动键盘方向键也不能聚焦进@的标签,光标需自定移到当前标签最后
-
输入@后连续输入的非空内容作为搜索关键词
这些需求分析完后,我才发现,自己写的是个啥,所以日后还是需要先分析分析需求,之后下手再不迟。分析完之后,本菜鸟不会,连忙前往掘金社区,寻求大佬帮助,本文的vue3版本也是在此基础上建立的,有兴趣的朋友可以去看看这位大佬写的vue2思路
开始行动
大致效果
大体板块划分
此处是将弹框sandText.vue作为一个组件,将其分出去书写了,主体功能逻辑在sand.vue当中。
大致划分之后,需要构建思路,父组件sand.vue传三个参数给子组件sandText.vue
- queryString 搜索的关键字
- visible 控制子组件是否展示
- posittion 所展示的位置坐标
返废话不多说直接上代码
//sandText.vue
<template>
<div class="wrapper" :style="{ position: 'fixed', top: position.y + 'px', left: position.x + 'px' }">
<div v-if="!mockList.length" class="empty">无搜索结果</div>
<div v-for="(item, i) in mockList" :key="item.id" ref="usersRef" class="item" :class="{ 'active': i === index }"
@click="clickAt($event, item)" @mouseenter="hoverAt(i)">
<div class="name">{{ item.name }}</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, ref, watch } from 'vue'
//选项数据源
const mockData = [
{ name: '文本语言', id: 'HTML' },
{ name: 'CSS', id: 'CSS' },
{ name: 'Java', id: 'Java' },
{ name: 'JavaScript', id: 'JavaScript' }
]
const props = defineProps({
//是否展示
visible: {
type: Boolean,
default: true
},
//搜索关键字
queryString: {
type: String,
default: ''
},
//展示的位置
position: {
type: Object,
default: () => {
return {}
}
}
})
//监听父组件传入搜索值的变化
watch(
() => props.queryString,
(val) => { //匹配这个搜索值 匹配不成功则为空,成功则返回值
val ? mockList.value = mockData.filter(({ name }) => name.startsWith(val)) : mockList.value = mockData.slice(0)
}
)
const users = ref([]);
const index = ref(-1); //控制文本的下标
const mockList: any = ref(mockData);
const emit = defineEmits([ 'onHide', 'onPickUser'])
//鼠标悬浮选择下标
const hoverAt = (i: any) => {
index.value = i
}
//上下选择文本
const keyDownHandler = (e: any) => {
if (e.code === 'Escape') {
emit('onHide')
return
}
// 键盘按下 => ↓
if (e.code === 'ArrowDown') {
if (index.value >= mockList.value.length - 1) {
index.value = 0
} else {
index.value = index.value + 1
}
}
// 键盘按下 => ↑
if (e.code === 'ArrowUp') {
if (index.value <= 0) {
index.value = mockList.value.length - 1
} else {
index.value = index.value - 1
}
}
// 键盘按下 => 回车
if (e.code === 'Enter') {
if (mockList.value.length) {
const user = {
name: mockList[index.value].value.name,
id: mockList[index.value].value.id
}
emit('onPickUser', user)
index.value = -1
}
}
}
//将选中的user抛出给父组件
const clickAt = (e: any, item: any) => {
const user = {
name: item.name,
id: item.id
}
emit('onPickUser', user)
index.value = -1
}
//页面挂载
onMounted(() => {
document.addEventListener('keyup', keyDownHandler)
})
//页面销毁
onUnmounted(() => {
document.removeEventListener('keyup', keyDownHandler)
})
</script>
<style scoped lang="less">
.wrapper {
width: 238px;
border: 1px solid #e4e7ed;
border-radius: 4px;
background-color: #fff;
box-shadow: 0 2px 12px 0 rgb(0 0 0 / 10%);
box-sizing: border-box;
padding: 6px 0;
z-index: 100;
}
.empty {
font-size: 14px;
padding: 0 20px;
color: #999;
}
.item {
font-size: 14px;
padding: 0 20px;
line-height: 34px;
cursor: pointer;
color: #606266;
&.active {
background: #f5f7fa;
color: blue;
.id {
color: blue;
}
}
&:first-child {
border-radius: 5px 5px 0 0;
}
&:last-child {
border-radius: 0 0 5px 5px;
}
.id {
font-size: 12px;
color: rgb(83, 81, 81);
}
}
</style>
这块代码当中没有过多繁琐的步骤,就是一个弹窗的内容,没有过多的代码逻辑,需要监听父组件传的字段搜素值,从而改变页面选择框的数据.接下来才是重头戏
主要逻辑
html与样式
sand.vue
<template>
<div class="content">
<!--文本框-->
<div ref="divRef" class="editor" contenteditable @keyup="handleKeyUp" @keydown="handleKeyDown" />
<!--选项-->
<sandText v-if="showDialog" :visible="showDialog" :position="position" :query-string="queryString"
@onPickUser="handlePickUser" @onHide="handleHide" @onShow="handleShow" />
<el-button type="primary" @click="logA">获取数据</el-button>
<!-- <el-button ref="btn" type="text" @click="handleIn">@</el-button> -->
</div>
</template>
<style scoped lang="less">
.content {
width: 50%;
font-family: sans-serif;
h1 {
text-align: center;
}
}
.editor {
margin: 0 auto;
width: 100%;
height: 200px;
background: #fff;
border: 1px solid #ccc;
border-radius: 5px;
text-align: left;
padding: 10px;
overflow: auto;
line-height: 30px;
&-focus {
outline: none;
}
}
</style>
逻辑实现
全局变量
const userList: any = ref([]) //用户数据列表
const node = ref('') // 获取的节点
const user = ref('') // 选中项的内容
const endIndex = ref('') // 光标最后停留位置
const queryString = ref('') // 搜索值
const showDialog = ref(false) // 是否显示弹窗
const position: any = ref({
x: 0,
y: 0
}) // 弹窗显示位置
const divRef: any = ref(null)
键盘按下弹起事件
首先需要绑定键盘按下以及弹起事件,用于后续操作
// 键盘抬起事件
const handleKeyUp = async () => {
//匹配@ 出现@
if (showAt()) {
const node1 = getRangeNode() //获取当前聚焦节点
const endIndex1 = getCursorIndex() //获取光标位置
node.value = node1
endIndex.value = endIndex1
position.value = await getRangeRect() //获取坐标需要时间,需要异步获取
queryString.value = getAtUser() || ''
showDialog.value = true
} else {
showDialog.value = false
}
// 限制长度 为1000 截取
if (divRef.value.innerText.length > 1000) {
divRef.value.innerText = divRef.value.innerText.substr(
0,
1000
)
// 光标移动到最后
document.execCommand('selectAll', false, '')
//将选种网页中的全部内容,也就是全部文本,第二个参数为true,会显示对话框
let a: any = document.getSelection()
a.collapseToEnd()
}
}
// 键盘按下事件
const handleKeyDown = (e: any) => {
//当前@选择被打开
if (showDialog.value) {
if (
e.code === 'ArrowUp' ||
e.code === 'ArrowDown' ||
e.code === 'Enter'
) {
// 阻止浏览器默认动作 (页面跳转)
e.preventDefault()
}
}
}
// 隐藏选择框
const handleHide = () => {
showDialog.value = false
}
// 显示选择框
const handleShow = () => {
showDialog.value = true
}
键盘输入监听@符号
当用户输入字符之后,需要记录当前被聚焦的节点,以及当前的光标,获取文本内容,匹配是否存在@符号,进行弹窗位置获取操作,以及获取后续输入的内容操作,
// 获取光标位置
const getCursorIndex = () => {
const selection: any = window.getSelection() //窗口的selection对象,当前用户选择的文本
return selection.focusOffset // 选择开始处 focusNode 的偏移量
}
// 获取节点
const getRangeNode = () => {
const selection: any = window.getSelection()
return selection.focusNode // 选择的结束节点,正在聚焦的节点
}
// 匹配@
const showAt = () => {
const node = getRangeNode() //获取当前被聚焦的节点
if (!node || node.nodeType !== Node.TEXT_NODE) return false
const content = node.textContent || '' //获取节点文本
const regX = /@([^@\s]*)$/
const match = regX.exec(content.slice(0, getCursorIndex())) //输入到 当前光标位置 匹配@
return match && match.length === 2 //返回@符号 一个@长度为2
}
关于这个selection对象,我得多几句嘴,可以理解为当前用户所选择的文本对象,其中包含多种属性,用于记录光标、当前所选择的节点的信息,具体想要了解的可以看看这篇文章,关于window.getSelction()
弹窗位置以及用户信息
利用selection对象当中的getRangeAt(0) 。管理选择范围的通用对象,来获取光标的位置信息。
而在获取@后输入的内容当中,也是需要获取输入的文本内容,匹配到@值,以后后续的内容,如果匹配成功,则会获取到后续输入的内容,之后传值到子组件queryString,使用该关键字去查询。查询结果通过鼠标点击事件传回父组件,父组件获取该值后,用于创建节点标签。
// 弹窗出现的位置
const getRangeRect = async () => {
const selection: any = (window).getSelection()
const range = selection.getRangeAt(0) // 是用于管理选择范围的通用对象
const rect: any = await range.getClientRects()[0] // 择一些文本并将获得所选文本的范围
// const rect = range.getBoundingClientRect()
const LINE_HEIGHT = 30
if (rect === undefined) { //光标在行首,是undefined
divRef.value.innerHTML = '\u200b' //零字符
const rect = range.getClientRects()[0]
// console.log(rect);
let obj:any = { x: 0, y: 0 }
return obj
} else {
return {
x: rect.x,
y: rect.y + LINE_HEIGHT
}
}
}
// 获取 @ 用户 @后的数据
const getAtUser = () => {
const content = getRangeNode().textContent || ''
const regX = /@([^@\s]*)$/
const match = regX.exec(content.slice(0, getCursorIndex()))
if (match && match.length === 2) {
return match[1] //@ 后面跟着的字符 光标停留
}
return undefined
}
插入标签
父组件获取到子组件抛出的相关用户信息之后,开始创建节点,其实就是一个span标签,且需要设置为不可编辑属性,这样删除的时候可以一块删除,之后将创建后的标签,替换掉原来的@符号,并将光标移动到最后。
// 插入标签后 隐藏选择框 获取子组件抛出的user
const handlePickUser = (user1: any) => {
userList.value.push(user1)
replaceAtUser(user1)
user.value = user1
showDialog.value = false
}
const createAtButton = (user: any) => {
const btn = document.createElement('span')
btn.style.display = 'inline-block'
btn.dataset.user = JSON.stringify(user)
btn.className = 'at-button'
btn.contentEditable = 'false'
btn.textContent = `@${user.name}`
btn.style.color = 'blue'
const wrapper = document.createElement('span')
wrapper.style.display = 'inline-block'
wrapper.contentEditable = 'false'
const spaceElem = document.createElement('span')
spaceElem.style.whiteSpace = 'pre'
spaceElem.textContent = '\u200b'
spaceElem.contentEditable = 'false'
const clonedSpaceElem = spaceElem.cloneNode(true)
wrapper.appendChild(spaceElem)
wrapper.appendChild(btn)
wrapper.appendChild(clonedSpaceElem)
return wrapper
}
// 插入@标签
const replaceAtUser = (user: any) => {
const node1: any = node.value
if (node1 && user) {
const content = node1.textContent || ''
const endIndex1 = endIndex.value
const preSlice = replaceString(content.slice(0, endIndex1), '')
const restSlice = content.slice(endIndex1)
const parentNode = node1.parentNode
const nextNode = node1.nextSibling
const previousTextNode = new Text(preSlice)
const nextTextNode = new Text('\u200b' + restSlice) // 添加 0 宽字符
const atButton = createAtButton(user) //创建@user标签
parentNode.removeChild(node1)
// 插在文本框中
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: any = window.getSelection()
range.setStart(nextTextNode, 0)
range.setEnd(nextTextNode, 0)
selection.removeAllRanges()
selection.addRange(range)
}
}
获取信息
我们将用户信息存入到了span标签的data-set当中。我们想要获取到用户的信息,就需要使用字符匹配机制,在选入用户信息的时候,需要将该用户信息存入到我们的userLIst当中,如果输入完全部文本之后,其中的文本信息包含了我们的userList当中的任意一项就需要将其结果保存到一个数组中,这个数组包含的就是用户信息,但是免不了会多@几个人,设置多次@一个人,这个时候需要对数组做去重工作,利用id唯一性,进行去重操作,获取新的数组,即为@的用户对象。
// 数组去重
const distinct = (arr: any, key: any) => {
var newObj: any = {}
var newArr: any = []
for (var i = 0; i < arr.length; i++) {
var item = arr[i]
if (!newObj[item[key]]) {
newObj[item[key]] = newArr.push(item)
}
}
return newArr
}
//格式化
const escape2Html = (str: any) => {
// 格式化
var arrEntities: any = { lt: '<', gt: '>', nbsp: ' ', amp: '&', quot: '"' }
return str.replace(/&(lt|gt|nbsp|amp|quot);/gi, function (all: any, t: any) {
return arrEntities[t]
})
}
//获取输入的@用户的信息
const logA = () => {
const text = divRef.value.innerHTML
const text2 = escape2Html(text)
let list: any = []
for (let index = 0; index < userList.value.length; index++) {
if (text2.includes(`"id":"${userList.value[index].id}"`)) {
list.push(userList.value[index])
}
}
list = distinct(list, 'id') //获取到@的id和name
console.log(divRef.value.innerText, list)
}
主体全部代码
sand.vue
<template>
<div class="content">
<!--文本框-->
<div ref="divRef" class="editor" contenteditable @keyup="handleKeyUp" @keydown="handleKeyDown" />
<!--选项-->
<sandText v-if="showDialog" :visible="showDialog" :position="position" :query-string="queryString"
@onPickUser="handlePickUser" @onHide="handleHide" @onShow="handleShow" />
<el-button type="primary" @click="logA">获取数据</el-button>
<!-- <el-button ref="btn" type="text" @click="handleIn">@</el-button> -->
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import sandText from './sandText.vue'
const userList: any = ref([]) //用户数据列表
const node = ref('') // 获取到节点
const user = ref('') // 选中项的内容
const endIndex = ref('') // 光标最后停留位置
const queryString = ref('') // 搜索值
const showDialog = ref(false) // 是否显示弹窗
const position: any = ref({
x: 0,
y: 0
}) // 弹窗显示位置
const divRef: any = ref(null)
// 数组去重
const distinct = (arr: any, key: any) => {
var newObj: any = {}
var newArr: any = []
for (var i = 0; i < arr.length; i++) {
var item = arr[i]
if (!newObj[item[key]]) {
newObj[item[key]] = newArr.push(item)
}
}
return newArr
}
//格式化
const escape2Html = (str: any) => {
// 格式化
var arrEntities: any = { lt: '<', gt: '>', nbsp: ' ', amp: '&', quot: '"' }
return str.replace(/&(lt|gt|nbsp|amp|quot);/gi, function (all: any, t: any) {
return arrEntities[t]
})
}
//获取输入的@用户的信息
const logA = () => {
const text = divRef.value.innerHTML
const text2 = escape2Html(text)
let list: any = []
for (let index = 0; index < userList.value.length; index++) {
if (text2.includes(`"id":"${userList.value[index].id}"`)) {
list.push(userList.value[index])
}
}
list = distinct(list, 'id') //获取到@的id和name
console.log(divRef.value.innerText, list)
}
//初始化
onMounted(() => {
handleIn()
})
//初始化各项数据
const handleIn = async () => {
// divRef.value.focus()
document.execCommand('selectAll', false, '')
let a: any = document.getSelection()
a.collapseToEnd()
const node1 = getRangeNode()
const endIndex1 = getCursorIndex()
node.value = node1
endIndex.value = endIndex1
position.value = await getRangeRect() //需要异步获取
queryString.value = getAtUser() || ''
showDialog.value = false
}
// 获取光标位置
const getCursorIndex = () => {
const selection: any = window.getSelection() //窗口的selection对象,当前用户选择的文本
return selection.focusOffset // 选择开始处 focusNode 的偏移量
}
// 获取节点
const getRangeNode = () => {
const selection: any = window.getSelection()
return selection.focusNode // 选择的结束节点,正在聚焦的节点
}
// 弹窗出现的位置
const getRangeRect = async () => {
const selection: any = (window).getSelection()
const range = selection.getRangeAt(0) // 是用于管理选择范围的通用对象
const rect: any = await range.getClientRects()[0] // 择一些文本并将获得所选文本的范围
// const rect = range.getBoundingClientRect()
const LINE_HEIGHT = 30
if (rect === undefined) { //光标在行首,是undefined
divRef.value.innerHTML = '\u200b' //零字符
const rect = range.getClientRects()[0]
// console.log(rect);
let obj:any = { x: 0, y: 0 }
return obj
} else {
return {
x: rect.x,
y: rect.y + LINE_HEIGHT
}
}
}
// 是否展示 @ 匹配@
const showAt = () => {
const node = getRangeNode() //获取当前被聚焦的节点
if (!node || node.nodeType !== Node.TEXT_NODE) return false
const content = node.textContent || '' //获取节点文本
const regX = /@([^@\s]*)$/
const match = regX.exec(content.slice(0, getCursorIndex())) //输入到 当前光标位置 匹配@
return match && match.length === 2 //返回@符号 一个@长度为2
}
// 获取 @ 用户 @后的数据
const getAtUser = () => {
const content = getRangeNode().textContent || ''
const regX = /@([^@\s]*)$/
const match = regX.exec(content.slice(0, getCursorIndex()))
if (match && match.length === 2) {
return match[1] //@ 后面跟着的字符 光标停留
}
return undefined
}
// 创建整个@标签
const createAtButton = (user: any) => {
const btn = document.createElement('span')
btn.style.display = 'inline-block'
btn.dataset.user = JSON.stringify(user)
btn.className = 'at-button'
btn.contentEditable = 'false'
btn.textContent = `@${user.name}`
btn.style.color = 'blue'
const wrapper = document.createElement('span')
wrapper.style.display = 'inline-block'
wrapper.contentEditable = 'false'
const spaceElem = document.createElement('span')
spaceElem.style.whiteSpace = 'pre'
spaceElem.textContent = '\u200b'
spaceElem.contentEditable = 'false'
const clonedSpaceElem = spaceElem.cloneNode(true)
wrapper.appendChild(spaceElem)
wrapper.appendChild(btn)
wrapper.appendChild(clonedSpaceElem)
return wrapper
}
const replaceString = (raw: any, replacer: any) => {
return raw.replace(/@([^@\s]*)$/, replacer)
}
// 插入@标签
const replaceAtUser = (user: any) => {
const node1: any = node.value
if (node1 && user) {
const content = node1.textContent || ''
const endIndex1 = endIndex.value
const preSlice = replaceString(content.slice(0, endIndex1), '')
const restSlice = content.slice(endIndex1)
const parentNode = node1.parentNode
const nextNode = node1.nextSibling
const previousTextNode = new Text(preSlice)
const nextTextNode = new Text('\u200b' + restSlice) // 添加 0 宽字符
const atButton = createAtButton(user) //创建@user标签
parentNode.removeChild(node1)
// 插在文本框中
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: any = window.getSelection()
range.setStart(nextTextNode, 0)
range.setEnd(nextTextNode, 0)
selection.removeAllRanges()
selection.addRange(range)
}
}
// 键盘抬起事件
const handleKeyUp = async () => {
//匹配@ 出现@
if (showAt()) {
const node1 = getRangeNode()
const endIndex1 = getCursorIndex() //获取光标位置
node.value = node1
endIndex.value = endIndex1
position.value = await getRangeRect() //获取坐标需要时间,需要异步获取
queryString.value = getAtUser() || ''
showDialog.value = true
} else {
showDialog.value = false
}
// 限制长度 为1000 截取
if (divRef.value.innerText.length > 1000) {
divRef.value.innerText = divRef.value.innerText.substr(
0,
1000
)
// 光标移动到最后
document.execCommand('selectAll', false, '')
//将选种网页中的全部内容,也就是全部文本,第二个参数为true,会显示对话框
let a: any = document.getSelection()
a.collapseToEnd()
}
}
// 键盘按下事件
const handleKeyDown = (e: any) => {
//当前@选择被打开
if (showDialog.value) {
if (
e.code === 'ArrowUp' ||
e.code === 'ArrowDown' ||
e.code === 'Enter'
) {
// 阻止浏览器默认动作 (页面跳转)
e.preventDefault()
}
}
}
// 插入标签后 隐藏选择框 获取子组件抛出的user
const handlePickUser = (user1: any) => {
userList.value.push(user1)
replaceAtUser(user1)
user.value = user1
showDialog.value = false
}
// 隐藏选择框
const handleHide = () => {
showDialog.value = false
}
// 显示选择框
const handleShow = () => {
showDialog.value = true
}
</script>
<style scoped lang="less">
.content {
width: 50%;
font-family: sans-serif;
h1 {
text-align: center;
}
}
.editor {
margin: 0 auto;
width: 100%;
height: 200px;
background: #fff;
border: 1px solid #ccc;
border-radius: 5px;
text-align: left;
padding: 10px;
overflow: auto;
line-height: 30px;
&-focus {
outline: none;
}
}
</style>
总结
@功能对于我这种小白来说也是一个小小的挑战,也是技术上的拓展,同时让我感触到前端的博大精深,自己所学仍然是沧海一粟,不管怎么说,还是需要跟大佬们多多学习,有时间多逛逛技术社区才是!
我是小白,分享自我学习心得,咱们一起学习!