Skip to content
On this page

前端vue2实现文本框@功能

很久没有动vue2项目了,这两天老项目来了新迭代需求,需要实现一个评论功能,评论时可以@相关人员,并且消息提醒被@的人。记录一下实现过程,虽然是用vue2写的,但是其实整体逻辑都是操作dom,跟框架关系不大,读者可以很简单的修改成其他框架代码。

需求

  1. 文本输入时,输入@弹出人员选择框。
  2. 人员选择框可以上下键切换,回车键选择,或者鼠标点选。
  3. 人员选择框可以筛选输入。
  4. 选择人员后```@XXX 变色显示。
  5. 点击@的标签时,全选整个@XXXX
  6. 整个@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. 定位弹窗位置并且打开弹窗

这里博主使用了getBoundingClientRectAPI,配合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>

上次更新于: