前端vue2实现文本框@功能
很久没有动vue2项目了,这两天老项目来了新迭代需求,需要实现一个评论功能,评论时可以@相关人员,并且消息提醒被@的人。记录一下实现过程,虽然是用vue2写的,但是其实整体逻辑都是操作dom,跟框架关系不大,读者可以很简单的修改成其他框架代码。
需求
- 文本输入时,输入@弹出人员选择框。
- 人员选择框可以上下键切换,回车键选择,或者鼠标点选。
- 人员选择框可以筛选输入。
- 选择人员后```@XXX 变色显示。
- 点击@的标签时,全选整个
@XXXX
。 - 整个
@XXXX
整体被删除。
效果预览
相关API
- const selection = window.getSelection() 获取Selection对象
- selection.getRangeAt(0) 获取当前光标的选择对象
- selection.focusNode 当前聚焦的节点
- selection.focusOffset 光标在当前节点上的偏移量
- range.setStart 设置光标选择的起始位置
- range.setEnd 设置光标选择的结束位置
- range.deleteContents() 删除选择的内容
- range.collapse() 将光标前后折叠起来,也就是让光标最后位置
- ✨contenteditable 使元素变成可编辑模式,富文本框的常用属性
代码实现
1. 如何识别输入了@
可能你会想到使用keypress、keyup、keydown事件,但是他们都存在问题。
- 这里分享一个前端冷知识,当一个div被设置contenteditable属性后,如果没有输入任何字符,浏览器不会认为这是一个输入框,所以当没有任何字符时,你输入了@,触发keypress/keydown事件,在事件处理方法中获取到的range对象并不是期望的光标,而是整个div。
- 而keyup事件并没有这个问题,因为当keyup触发时,div中已经有字符了,所以能够正确的获取到range对象。
- 但是keyup存在一个问题,仔细想想,@输入需要按两个键,分别时Shift和2键,在keyup中如果想要让key为@,那么必须先松开2键在松开Shift键,如果先松开了Shift键那么,keyup触发的key是2而不是@。当用户快速输入时,先松开2还是Shift是无法保证的,看着像是个偶现性bug。
使用input事件才是最佳处理方式,判断e.data === '@'
即可。
js
// 打开选择框处理方法
textInputHandler(e) {
// 如果按下@
if (e.data === '@') {
// ....实际操作
}
}
2. 定位弹窗位置并且打开弹窗
这里博主使用了getBoundingClientRect
API,配合v-show
来实现,当然其他方式也可以。注意这一步需要提前存储@的位置信息,方便后面替换成<span>
标签。
js
// 存储光标位置,用于选择后定位
this.selectionCache.focusNode = selection?.focusNode
this.selectionCache.focusOffset = selection?.focusOffset
// 计算选择框位置
const rect = range.getBoundingClientRect()
const parentRect = this.$refs.textInput.getBoundingClientRect()
// 显示选择框
this.$refs.selectBox.style.top = rect.top - parentRect.top - 8 + 'px'
this.$refs.selectBox.style.left = rect.right - parentRect.left + 'px'
this.showSelectBox = true
// 聚焦输入框
this.$nextTick(() => this.$refs.selectBoxInput.focus())
具体可以看最后的完整代码部分。
3. 点击后,删除原来的@符号,插入span标签
js
// 选择后处理方法
selectHandler(item) {
const range = window.getSelection()?.getRangeAt(0)
// 删除原来的@符号
range.setStart(this.selectionCache.focusNode, this.selectionCache.focusOffset - 1)
range.setEnd(this.selectionCache.focusNode, this.selectionCache.focusOffset)
range.deleteContents()
// 插入span
const span = document.createElement('span')
span.className = 'username_light'
span.contentEditable = 'false'
span.innerText = `@${item}`
range.insertNode(span)
// 光标移动到最后
range.collapse()
// 聚焦到原输入框
this.$refs.textInput.focus()
// 关闭选择框
this.filterValue = ''
this.showSelectBox = false
},
注意这里的span.contentEditable = 'false'
,如果没有设置成false,当你继续输入时,输入的字符会输入到最后的这个span标签中,与我们的逻辑不符。
4. 判断键盘事件
js
keyupHandler(e) {
switch (e.key) {
case 'ArrowUp': {
this.currentIndex =
this.currentIndex === 0 ? this.filterListArr.length - 1 : this.currentIndex - 1
break
}
case 'ArrowDown': {
this.currentIndex =
this.currentIndex === this.filterListArr.length - 1 ? 0 : this.currentIndex + 1
break
}
case 'Enter': {
if (!this.filterListArr[this.currentIndex]) return
this.selectHandler(this.filterListArr[this.currentIndex])
this.currentIndex = 0
break
}
default: {
this.currentIndex = 0
}
}
},
5. 控制点击事件,当选择的是@XXXX
时,选择整个标签,方便删除
js
textClickHandler({ target }) {
if (target.className === 'username_light') {
const selection = window.getSelection()
const range = selection?.getRangeAt(0)
range.selectNode(target)
}
},
完整组件代码
vue
<template>
<div class="chat-box">
<div
class="text-input"
ref="textInput"
contenteditable
@input="textInputHandler"
@click="textClickHandler"
@blur="textBlurHandler"
/>
<div class="select-box" ref="selectBox" v-show="showSelectBox">
<input v-model="filterValue" ref="selectBoxInput" @keyup="keyupHandler" />
<div
v-for="(val, idx) in filterListArr"
:key="val"
:class="{ 'select-box-item': true, 'select-box-item_current': currentIndex === idx }"
@click="selectHandler(val)"
>
{{ val }}
</div>
</div>
</div>
</template>
<script>
export default {
props: {
list: Array,
},
data() {
return {
showSelectBox: false,
filterValue: '',
// 按下@存储的光标位置信息
selectionCache: {
focusNode: null,
focusOffset: null,
},
currentIndex: 0, // 当前的选择的索引
}
},
computed: {
// 筛选后的列表
filterListArr() {
return this.list.filter((val) => val.indexOf(this.filterValue) > -1)
},
},
methods: {
// 打开选择框处理方法
textInputHandler(e) {
// 如果按下@
if (e.data === '@') {
const selection = window.getSelection()
const range = selection?.getRangeAt(0)
console.log(range)
// 存储光标位置,用于选择后定位
this.selectionCache.focusNode = selection?.focusNode
this.selectionCache.focusOffset = selection?.focusOffset
// 计算选择框位置
const rect = range.getBoundingClientRect()
const parentRect = this.$refs.textInput.getBoundingClientRect()
// 显示选择框
this.$refs.selectBox.style.top = rect.top - parentRect.top - 8 + 'px'
this.$refs.selectBox.style.left = rect.right - parentRect.left + 'px'
this.showSelectBox = true
// 聚焦输入框
this.$nextTick(() => this.$refs.selectBoxInput.focus())
}
},
// 选择后处理方法
selectHandler(item) {
const range = window.getSelection()?.getRangeAt(0)
// 删除原来的@符号
range.setStart(this.selectionCache.focusNode, this.selectionCache.focusOffset - 1)
range.setEnd(this.selectionCache.focusNode, this.selectionCache.focusOffset)
range.deleteContents()
// 插入span
const span = document.createElement('span')
span.className = 'username_light'
span.contentEditable = 'false'
span.innerText = `@${item}`
range.insertNode(span)
// 光标移动到最后
range.collapse()
// 聚焦到原输入框
this.$refs.textInput.focus()
// 关闭选择框
this.filterValue = ''
this.showSelectBox = false
},
// 用户选择回车事件
keyupHandler(e) {
switch (e.key) {
case 'ArrowUp': {
this.currentIndex =
this.currentIndex === 0 ? this.filterListArr.length - 1 : this.currentIndex - 1
break
}
case 'ArrowDown': {
this.currentIndex =
this.currentIndex === this.filterListArr.length - 1 ? 0 : this.currentIndex + 1
break
}
case 'Enter': {
if (!this.filterListArr[this.currentIndex]) return
this.selectHandler(this.filterListArr[this.currentIndex])
this.currentIndex = 0
break
}
default: {
this.currentIndex = 0
}
}
},
// 原输入框点击事件
textClickHandler({ target }) {
if (target.className === 'username_light') {
const selection = window.getSelection()
const range = selection?.getRangeAt(0)
range.selectNode(target)
}
},
// 原输入框blur事件
textBlurHandler() {
this.$emit('input', this.$refs.textInput.innerHTML.replaceAll(' contenteditable="false"', ''))
},
},
created() {},
}
</script>
<style lang="scss" scoped>
.chat-box {
position: relative;
.text-input {
outline: none;
border: 1px solid #dcdfe6;
min-height: 100px;
line-height: 34px;
&:focus {
border-color: #32a1ff;
}
}
.select-box {
position: absolute;
min-width: 100px;
border: 1px solid #e4e7ed;
box-shadow: 0 2px 12px 0 #00000012;
background-color: #fff;
& > input {
outline: none;
border: 1px solid #32a1ff;
height: 34px;
line-height: 34px;
}
.select-box-item {
height: 34px;
line-height: 34px;
padding: 0 10px;
cursor: pointer;
&:hover {
background-color: #f5f7fa;
}
}
.select-box-item_current {
background-color: #f5f7fa;
}
}
}
</style>
<style lang="scss">
.chat-box {
.username_light {
display: inline-block;
color: #32a1ff;
margin: 0 5px;
}
}
</style>