近期需要接到一个需求,需要在输入框中实现@通知用户的功能,这个功能现在也有很多应用都有,像我们常用的QQ空间,微博这些,开始看到这个需求,心里一阵惊恐,没做过啊~~
我们大体的思路就是:当监听到用户输入@的时候我们弹出人员选择器,这时候我们需要记住现在光标所在的位置,当用户选择人员完毕之后,我们创建一个span标签在插入到我们刚刚记录光标的位置,并且把我们输入的@删除,将光标放在这个节点的最后。
按住shift + @ 的时候,弹出人员选择器
人员选择器要跟随光标的位置出现
选择时 @的用户标签插入当前的光标位置中
生成@的用户标签的规则是:高亮、携带用户ID、一键删除信息、不可以编辑。
文本框要随内容自适应高度
用户点击生成的标签或移动键盘方向键也不能聚焦进@的标签,光标需自定移到当前标签最后
输入@后连续输入的非空内容作为搜索关键词
普通文本输入框实现不了这个功能,这里利用了wangEditor的富文本编辑器功能作为基础载体,
wangeditor的官方文档:www.wangeditor.com/doc/
1:wangeditor安装:
npm i wangeditor --save
2:使用
引入
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)表示获取当前的第一个选区
这里面有很多Selection跟Range的详细介绍以及使用可以参考文档:developer.mozilla.org/zh-CN/docs/…
@的功能的监听
键盘的@字符英文的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 = ' '
spanNode3.innerHTML = ' '
// 将生成内容打包放在 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(
` @${userName} `
)
}
this.close()
},
@内容搜索
在@触发后,用户还可以继续输入搜索内容,输入空格或者回车关闭选择框,实现方式就是监听文本内容,当我们触发@的时候getWord标识为true,然后截取用户输入的内容作为搜索关键词,而当用户输入空格或者tab键的时候我们关闭选择器,用户敲击回车的时候我们默认取搜索结果的第一条数据。
// 内容改变监听
onchange(html) {
const { getWord, isDelete } = this
const str = this.editor.txt.text()
// 输入内容空格替换
const text = str.replace(/ /gi, ' ').trim()
// 替换空标签
const regex = /]*>/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)
}
},
判断空格
这里需要注意的是,判断是否输入的是空格我们不能单单使用一个空格取判断,我们在生成的时候空格是用的是 来生成的,这里我们调试发现,跟普通的空格是有区别的,普通空格的ASCII码是32,这里富文本的空格ASCII码是160 (不间断空格:就是页面上的 ‘& nbsp’ 所产生的空格。)。 下面这个方法就是判断空格
// 判断是否是空格
isSpaceReg(str) {
// 判断是否是空格 普通空格的ASCII码是32,这里富文本的空格ASCII码是160 (不间断空格:就是页面上的 所产生的空格。)。
// 不间断空格有个问题,就是它无法被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(/[\s\S]*? /gi, '')
str = str.replace(//gi, '')
str = str.replace(/?[^>]*>/g, '')
str = str.replace(/[ | ]*\n/g, '')
str = str.replace(/ /gi, '')
return str
}
1、在生成@的标签时,记录光标位置的时机要在键盘抬起时候记录,这时候@已经生成,如果在键盘按下瞬间去记录会导致最终@标签回填的位置总是相差一个单位。
2、普通空格跟‘ ’的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、有信心!!!这里无疑就是文本的增删改查,事件也都具备,只是考虑的场景比较多,但的绝大部分场景在熟悉Selection跟Range后外加思考,结合一些原生的dom事件都能解决,可能时间上花的多一点!