一:前言

近期需要接到一个需求,需要在输入框中实现@通知用户的功能,这个功能现在也有很多应用都有,像我们常用的QQ空间,微博这些,开始看到这个需求,心里一阵惊恐,没做过啊~~

二:思路

我们大体的思路就是:当监听到用户输入@的时候我们弹出人员选择器,这时候我们需要记住现在光标所在的位置,当用户选择人员完毕之后,我们创建一个span标签在插入到我们刚刚记录光标的位置,并且把我们输入的@删除,将光标放在这个节点的最后。

三:需求拆解

按住shift + @ 的时候,弹出人员选择器

人员选择器要跟随光标的位置出现

选择时 @的用户标签插入当前的光标位置中

生成@的用户标签的规则是:高亮、携带用户ID、一键删除信息、不可以编辑。

文本框要随内容自适应高度

用户点击生成的标签或移动键盘方向键也不能聚焦进@的标签,光标需自定移到当前标签最后

输入@后连续输入的非空内容作为搜索关键词

四:准备工作

普通文本输入框实现不了这个功能,这里利用了wangEditor的富文本编辑器功能作为基础载体,

wangeditor的官方文档:https://www.wangeditor.com/doc/

1:wangeditor安装:

 npm i wangeditor --save

2:使用

 <div ref="editor"
           id="editor"
           @keyup='enterEvUp($event)'
           @keydown="enterEv($event)">
           </div>

引入

import E from 'wangeditor'

3:初始化编辑器

这里通过各种属性来设置编辑器的基础功能

  // 初始换编辑器
    initEditor() {
      const { placeholder, content } = this
      const editor = new E(this.$refs.editor)
      editor.config.placeholder = placeholder
      editor.config.menus = [] // 显示菜单按钮
      editor.config.showFullScreen = false // 不显示全屏按钮
      editor.config.pasteIgnoreImg = true // 如果复制的内容有图片又有文字,则只粘贴文字,不粘贴图片。
      editor.config.height = '100'
      editor.config.zIndex = 2 // 编辑器 z-index 默认为 10000
      editor.config.focus = false // 取消自动 focus
      editor.config.onchange = html => {
        this.onchange(html)
      }
      // 事件绑定
      editor.txt.eventHooks.clickEvents.push(this.clickEvents) // 点击事件
      editor.txt.eventHooks.pasteEvents.push(this.pasteEvents) // 粘贴事件
      editor.create()
      editor.txt.html(content) // 设置编辑器内容
      this.editor = editor
      // 销毁编辑器
      this.$once('hook:beforeDestroy', () => {
        this.editor.destroy()
        this.editor = null
      })
    },

五:@功能的实现

@基础功能实现

编辑器基本环境好了后我们正式开始实现@功能,首先我们监听键盘事件:按住shift + @ 的时候,弹出人员选择框,这里监听的触发的时候需要注意的点是不同输入模式下,键盘上@符号的keyCode数字不一样,在英文模式下keyCode的值是50,而中文输入法下标点符号keyCode都是一样的:229,这里需要注意。

 // keydown触发事件(按下键盘时候触发)
    enterEv(e) {
      const { keyCode, code } = e
      
      // 英文code是 50, 判断是否按住shift + @键
      // 中文输入法下标点符号keyCode都是一样的:229,推荐使用event.code或event.key作为@的判断。
       const isCode =
        ((keyCode === 229 && e.key === '@') ||
          (keyCode === 229 && e.code === 'Digit2') ||
          keyCode === 50) &&
        e.shiftKey
      if (isCode) {
        this.getPosition()
        this.getWord = true
        this.showFlag = 'visible'
        this.userName = ''
      } else if (code === 'Backspace' || e.key === 'Backspace') {
        // 删除键
        this.deleteKey()
      } else if (code === 'Enter' || +keyCode === 13) {
        // 回车键
        const { getWord, spinShow, contactList, listIndex, stopInput } = this
        if (getWord && !spinShow && contactList.length > 0 && !stopInput) {
          this.selectPerson(contactList[listIndex])
          e.preventDefault ? e.preventDefault() : (e.returnValue = false)
        }
      } else {
        // 当用户中文输入法下,没有选择输入内容时候就敲击回车,这时候选取内容即可其他不做处理
        this.stopInput = this.showFlag === 'visible' && !this.spinShow
      }
      this.isDelete = code === 'Backspace' || e.key === 'Backspace'
    },

记录光标的位置

这里记录光标的位置是为了再触发@的时候,下拉框定位到当期@出现的位置,因此,当我们出入@触发的时候,就记录当前光标所在的坐标位置,以及当前光标所在的为本位置。这里光标的像素位置我们用的插件 caret-pos 来获取,caret-pos插件使用也很简单,直接npm安装即可这里不详细介绍。

记录光标的坐标

这里下拉框出现的位置会受到页面高度的影响,因此,如果整体的页面如果有滚动条,我们就需要通过计算滚动条的位置来设置弹窗出现的位置,这里的细节就是当输入的文字靠近输入框最右侧的时候,我们需要把下拉框定位到光标的左侧显示,这样页面就不会被挤压变形。

import { position } from 'caret-pos'

    // 获取@位置设置下拉框出现位置
    getPosition() {
    // 滚动条滚动高度
      const scrollTop =
        document.body.scrollTop || document.documentElement.scrollTop
      const width = this.$refs.editor.clientWidth
      const ele = this.editor.$textElem.elems[0]
      const pos = position(ele)
      this.left = pos.left + 20
      // 当靠近最右边的时候,输入框在光标左边显示,300是下拉框的默认宽度,其他数字就是调整页面位置
      if (width - pos.left <= 300) {
        this.left = pos.left - 280
      }
      this.top = pos.top + 20 - scrollTop
    },

保存当前光标的文本位置

我们还需要记录光标在文本的位置,因为我们选中人员的时候,需要将内容填充当刚才光标的位置,保存方式也很简单,我们只需要获取当前光标所在的选区,里面包含了光标所在的各种信息,用一个全局变量保存即可。

       const range = getSelection().getRangeAt(0)
        const textNode = range.startContainer
        const pos = this.getCursortPosition(textNode)
        this.cursorPos = pos

getSelection()表示用户选择的文本范围或光标的当前位置

getRangeAt(0)表示获取当前的第一个选区

这里面有很多SelectionRange的详细介绍以及使用可以参考文档:https://developer.mozilla.org/zh-CN/docs/Web/API/Selection

@的功能的监听

键盘的@字符英文的code是50,还有判断同时是否按住shift + @键,而这里需要注意的点是,中文输入法下,标点符号的keyCode都是一样的,都是229,这里最好使用event.code或者event.key来作为输入是否是@的判断条件

      // 英文code是 50, 判断是否按住shift + @键
      // 中文输入法下标点符号keyCode都是一样的:229,推荐使用event.code或event.key作为@的判断。
      const isCode =
        ((e.keyCode === 229 && e.key === '@') ||
          (e.keyCode === 229 && e.code === 'Digit2') ||
          e.keyCode === 50) &&
        e.shiftKey

生成 @的标签,并且高亮、携带用户ID。

生成@的用户标签的规则是:高亮、携带用户id跟userCode、一键删除信息、不可以编辑。生成逻辑也很简单,就是创建一个span标签,插入到光标的位置,然后删除用户输入的文本内容。

    // 选人回填数据
    selectPerson(data) {
      const { userCode, userId } = data

      const selection = this.position.selection
      const range = this.position.range

      // 生成需要显示的内容,包括一个 span 和前后各一个空格。
      const spanNode1 = document.createElement('span')
      const spanNode2 = document.createElement('span')
      const spanNode3 = document.createElement('span')

      spanNode1.className = 'at-text'
      spanNode1.innerHTML = `@${data.userName}` // @的文本信息
      spanNode1.dataset.userId = userId // 用户ID、为后续解析富文本提供
      spanNode1.dataset.userCode = userCode // 用户userCode
      // spanNode1.contentEditable = false // 当设置为false时,富文本会把成功文本视为一个节点。
      spanNode2.innerHTML = '&nbsp;'
      spanNode3.innerHTML = '&nbsp;'
      // 将生成内容打包放在 Fragment 中,并获取生成内容的最后一个节点,也就是空格。
      const frag = document.createDocumentFragment()

      let node, lastNode
      frag.appendChild(spanNode3)
      frag.appendChild(spanNode1)
      frag.appendChild(spanNode2)

      // 如果是键盘触发的默认删除面前的@以及@搜索的内容
      const textNode = range.startContainer
      const { userName } = this
      const num = userName.length
      range.setStart(textNode, range.endOffset - 1)
      range.setEnd(textNode, range.endOffset + num)
      range.deleteContents()

      // 将 Fragment 中的内容放入 range 中,并将光标放在空格之后。
      while ((node = spanNode2.firstChild)) {
        lastNode = frag.appendChild(node)
      }
      range.insertNode(frag)
      // 设置光标位置
      selection.collapse(lastNode, 1)
      // 判断是否有文本、是否有坐标
      if (this.editor.txt.text() && this.position && range) {
        range.insertNode(frag)
      } else {
        // 如果没有内容一开始就插入数据特别处理
        this.editor.txt.append(
          `<span class='at-text' data-userId="${userId}" data-userCode="${userCode}">&nbsp;@${userName}&nbsp;</span>`
        )
      }
      this.close()
    },

@内容搜索

在@触发后,用户还可以继续输入搜索内容,输入空格或者回车关闭选择框,实现方式就是监听文本内容,当我们触发@的时候getWord标识为true,然后截取用户输入的内容作为搜索关键词,而当用户输入空格或者tab键的时候我们关闭选择器,用户敲击回车的时候我们默认取搜索结果的第一条数据。

    // 内容改变监听
    onchange(html) {
      const { getWord, isDelete } = this
      const str = this.editor.txt.text()
      // 输入内容空格替换
      const text = str.replace(/&nbsp;/gi, ' ').trim()
      // 替换空标签
      const regex = /<span[^<>]*><\/span>/gm
      const content = html.replace(regex, '')
      // @触发后的输入处理
      if (getWord && str) {
        const range = getSelection().getRangeAt(0)
        const index = this.getCursortPosition(range.startContainer)
        // 用户在输入回车,换行时不记录
        if (range.startContainer.innerText === '\n') {
          this.close()
          return
        }
        const arr = range.startContainer.data.substring(0, index).split('@')
        const value = arr[arr.length - 1]
        const isSpaceStr = value.substring(value.length - 1, value.length)
        const isSpace = this.isSpaceReg(isSpaceStr)
        if (value === '') {
          // 保存光标位置,在@生成时候光标的位置,
          const selection = getSelection()
          this.position = {
            range: selection.getRangeAt(0),
            selection: selection
          }
        }
        if (isSpace) {
          // 空格或者tab键时关闭人员选择器
          this.close()
          return
        }
        this.userName = value
        this.spinShow = true
        this.search()
      }
      // 删除span
      if (isDelete) {
        this.deleteAtSpan()
      }
      const data = {
        preview: text,
        html: content
      }
      this.text = text
      this.$emit('change', data)
    },

删除整块带有@内容标签,

因为我们在生成@内容的时候contentEditable属性我们没有设置成false,所以此时的整块标签其实是可以编辑的,这样显然不行,我们删除的时候要删除一整块,实现的方法就是当我们删除的内容包含到@内容的时候,我们需要手动取删除整块标签。

删除的方法就是在我们删除内容的时候,取获取当前光标的位置,判断删除的内容是否是我们设置的标识className,如果是,我们就将整个选区扩大,包含整个@内容,然后删除其节点。range.deleteContents()方法是删除节点的文本内容,我们通过range.cloneContents()这个方法获取节点,然后将其节点也删除。

 // 删除整块带有@内容标签
    deleteAtSpan() {
      const selection = window.getSelection() // 获取当前选中区域
      const range = selection.getRangeAt(0)
      const { startOffset, endOffset } = range
      const textNodeStar = range.startContainer
      const textNodeEnd = range.endContainer
      // 获取节点
      const selectNode = range.cloneContents()
      const className =
        textNodeStar.parentNode.className ||
        textNodeEnd.parentNode.className ||
        ''
      if (className === 'at-text' && (+endOffset !== 0 || +startOffset !== 0)) {
        range.selectNodeContents(textNodeStar)
        range.selectNodeContents(textNodeEnd)
        range.deleteContents()
        if (selectNode.firstChild) {
          // 删除节点
          selectNode.removeChild(selectNode.firstChild)
        }
      }
    },

移动光标位置

当我们讲鼠标放到@的内容上时,我们光标要不可聚焦上去,要定位在当前点击的@内容的末尾处,这个功能就是移动光标位置,当我们点击@内容的时候,我们需要判断光标在此节点文本中的位置,以及这个节点的文本有多长,由此可以计算,我们需要向左或者向右移动多少个单位。

selection.modify(‘move’, ‘left’, ‘character’)方法就是移动光标的位置,这个方法接受三个参数,第一个参数是移动还是扩大选区

传入"move"来移动光标位置,或者``"extend"来扩展当前选区。

第二个是移动的方向,调整选区的方向。你可以传入"forward"或``"backward"来根据选区内容的语言书写方向来调整。或者使用"left"或"right"来指明一个明确的调整方向。

第三个参数是单位,调整的距离颗粒度。可选值有"character"、``"word"、``"sentence"、``"line"、``"paragraph"、``"lineboundary"、``"sentenceboundary"、``"paragraphboundary"、``"documentboundary"。

我们这里选择的是以字符来移动。

  // 移动光标
    moveCursor(direction) {
      try {
        const selection = window.getSelection() // 获取当前选中区域
        const range = getSelection().getRangeAt(0)
        const textNode = range.startContainer
        const pos = this.getCursortPosition(textNode)

        // 左移光标
        if (direction === 'left') {
          for (let i = 0; i < pos; i++) {
            selection.modify('move', 'left', 'character')
          }
          // 移动完成后再次检查,光标是否还在@所在标签
        } else {
          // 右移光标 多移动一个空格
          for (let i = 0; i < textNode.length - pos + 1; i++) {
            selection.modify('move', 'right', 'character')
          }
        }
        if (textNode) {
          this.moveDirection()
        }
      } catch (e) {
        // console.log(e)
      }
    },

判断空格

这里需要注意的是,判断是否输入的是空格我们不能单单使用一个空格取判断,我们在生成的时候空格是用的是&nbsp来生成的,这里我们调试发现,跟普通的空格是有区别的,**普通空格的ASCII码是32,这里富文本的空格ASCII码是160 (不间断空格:就是页面上的 ‘& nbsp’ 所产生的空格。)。**下面这个方法就是判断空格

 // 判断是否是空格
    isSpaceReg(str) {
      // 判断是否是空格  普通空格的ASCII码是32,这里富文本的空格ASCII码是160 (不间断空格:就是页面上的&nbsp;所产生的空格。)。
      // 不间断空格有个问题,就是它无法被trim()所裁剪,也无法被正则表达式的\s所匹配,
      // 也无法被StringUtils的isBlank()所识别,也就是说,无法像裁剪寻常空格那样移除这个不间断空格。
      // 利用不间断空格的Unicode编码来移除它,其编码为\u00A0。
      const regu = '^[\u00A0 ]+$'
      const re = new RegExp(regu)
      return re.test(str)
    },

粘贴除去样式:

原本这里wangEditor编辑器有个可以控制粘贴样式的过滤。但是测试过后这个属性并不能生效,因此我们需要自己定义粘贴事件。

 editor.config.pasteFilterStyle = false // 关闭粘贴样式的过滤----无效

这里编辑器在粘贴的时候会触发一个粘贴事件,里面会传递给我们粘贴的内容,我只需将内容的标签样式剔除,获取到里面的文本即可。

 // 自定义去除粘贴样式   不适用于 IE
      editor.config.pasteTextHandle = function (content) {
        if (content === '' && !content) return ''
        let str = content
        // 去除粘贴样式
        str = str.replace(/<xml>[\s\S]*?<\/xml>/gi, '')
        str = str.replace(/<style>[\s\S]*?<\/style>/gi, '')
        str = str.replace(/<\/?[^>]*>/g, '')
        str = str.replace(/[ | ]*\n/g, '')
        str = str.replace(/&nbsp;/gi, '')
        return str
      }

六:总结

1、在生成@的标签时,记录光标位置的时机要在键盘抬起时候记录,这时候@已经生成,如果在键盘按下瞬间去记录会导致最终@标签回填的位置总是相差一个单位。

2、普通空格跟‘&nbsp’的ASCII码不一致,导致调试期间,判断是否为空格的时候出错,普通键盘输入的空格是ASCII32,而& nbsp’ 生成的空格ASCII码是160 ,也叫不间断空格。

3、键盘的@字符英文的code是50,中文输入法下,标点符号的keyCode都是一样的,都是229,这里在触发@的条件时候容易忽略,这里最好使用event.code或者event.key来作为输入是否是@的判断条件。

4、PC端由于发帖跟页面列表是在同一个页面,因此在我们切换页面的时候,要记得弹出框的关闭(方案:监听路由),存储光标选区信息的时候不能跨页面缓存,这样会搞垮整个页面。不然两个页面之间的编辑器会互相干扰。

4、多看文档!!!还要看官方文档,百度的有时候不靠谱,光标相关的事件以及api要熟悉,开始的时候删除光标的文本一直找不到方法,多看文档后发现有办法实现,range.cloneContents()可以拿到光标选区的节点(这里其实也只是复制克隆的节点)。还有就是移动光标位置的方法: selection.modify(‘move’, ‘right’, ‘character’)(上文有介绍使用),当时看别百度的介绍使用的时候一脸懵,感觉好难啊!但是看官方文档后豁然开朗,So easy!

5、有信心!!!这里无疑就是文本的增删改查,事件也都具备,只是考虑的场景比较多,但的绝大部分场景在熟悉SelectionRange后外加思考,结合一些原生的dom事件都能解决,可能时间上花的多一点!

Logo

前往低代码交流专区

更多推荐