@功能,是一个“看起来简单”,实际上“要考虑特别多问题” 的功能需求。本文对这个常见的功能需求的实现进行了记录。
之前我在小程序里在没有 contenteditable 的input中实现了一个输入 @ 字符实现 at 选人功能的需求。无米之炊: 小程序内实现一个具有“@功能at功能”的输入框。当时由于小程序缺少富文本输入框,因此只能在纯 input 里面实现人名输入和用户信息还原。
如今咱们就要来实现 web 的版本了。web 端具有更加丰富的 api 给我们使用,但是其考虑的问题也会更多,因为web端用户的输入变换莫测(即用户可以使用鼠标、键盘或其他输入设备随意的选择、粘贴、控制文本框)。因此,web端如果采用拦截用户所有输入情况进行 "虚拟抽象层" 处理和渲染会有些力不从心。
本文最终还是采用通用的方案---contenteditable来实现,下面介绍其过程和思路。
明确需求
要做这个需求,我们需要首先明确 at功能需求 其本质上要包含哪些必要的功能以及一些特定的前置条件,这会直接影响到我们技术方案的设计。
前置条件明确
我们需要向产品确认联系人中是否有 “重名” 的联系人,如联系人中是否会有 2 个叫做 “小明” 的联系人。
如果不会重名,我们便可以使用一种 “类似新浪微博” 的方案,即无需管理和记录每个人的真实id,只需让用户在文本框内随意选择和输入文本和人名,等到最后用户点击保存提交微博时,将文本框内的字符串依次遍历反解(反解办法就是,先找到一个@字符作为开始,然后直到碰到一个空格作为结束,看一下该字符串是否等于某个人名账号)。
由于新浪微博他的人名(账户)是唯一的,因此他便采用了如上简便的做法。从我们随意输入和粘贴一段文本之后产生的bug来看,的确如此:
以上是我通过鼠标@或者文本输入插入了一些 齐鲁晚报 的人名。但是当我主动把其中某些字符删掉,或者把 "@齐鲁晚报" 后方的空格干掉,提交后微博便无法还原我at的人了。图上每个人名我都无法hover或点击(因为我破坏了人名反解的结构)。
但是,如果我从其他地方拷贝一个 "@齐鲁晚报" 的字符串,然后在其后方敲入一个空格,再提交,则微博可以获取到该用户。
因此,我们猜到新浪微博反解人名的办法是:寻找@字符及其后方的空格作为一个人名。我们查看新浪微博dom中是通过textarea实现的输入框,通过其 api限制以及上面的效果来看,其原理就是如我们所猜测的一样:基于输入内容的字符串进行反解而已,思路比较简单。
而本文我所面向的需求前置条件是:联系人具有可重复的人名。此时的技术方案便面临一个最大的问题:如何能反解出输入框中人名的信息?举例来说:假如我们输入框中的字符串内容是 "你好,@小明,我们一起吃饭吧,你叫上另外一个 @小明"。 那么,提交给后台存储时,我们比如告知后台第一个小明是1号小明,第二个小明是2号小明。如何做到呢?这个我们在下文讲述。
需求定义
OK,我们来基于 “人名可以重复” 这个前置条件,来定义一下我们的需求。
- 用户可以在文本框中输入任意的普通字符
- 用户可以通过输入一个 "@" 字符来调起一个选人浮层,选人浮层的位置必须放置在光标所在的位置,且能够根据窗口边缘进行适当的位置调整。例如下方空间不够时,则放到上方。我们来看看新浪微博:
这个是默认下方空间够用的时候。当下方不够时,浮层会出现在上方:
但是,经过测试,当右侧空间不够时,它并没有出现在左方:
理论上,最好的方案应该是:左上、左下、右上、右下每个方向都可以放置浮层,如果任何一个位置空间不够,则换一个位置放置。其放置也是有优先级的,例如右下方和右上方同时都能放下,则右下方优先级更高。其优先级顺序应该是:右下方、右上方、左下方、左上方。 - 当选人浮层出现后,用户可以通过键盘的“上”、“下”来操纵选人浮层上对应的人选,并可以通过“回车键”将人选填入到输入框中。当然,也可以支持鼠标选择。
- 输入框中的人名是一个整体。即:当用户鼠标在输入框中人名附近左右移动时,光标不能放置到人名中间。同时,用户在人名后方按下“退格”键,则会把当前人名整体瞬间删除。
- 支持模糊搜索。即:当用户输入 @ 时,弹出推荐的联系人,当他输入 "@小",则浮层里要把 "小明" "小红" "小x" 等搜索后的联系人。 且选人完毕后,要把选中的如 "小红" 填入到文本框中
- 用户输入完毕后,可以把文本框中所有字符串或人名转成对应的结构存储到后端存储。从而在其他页面可以展示。例如QQ空间发表说说后,后台需要给你输入的人名发送at通知,而且要在你的qq说说页面展示你发表的内容(当你鼠标放到人名上还可以展示用户卡片),如图:
技术上要考虑的点:
需求看起来简单,实际上这个功能是个前端大坑。我们看看这涉及到多少个要考虑的点,每个点都不是那么简单:
- 如何能反解出输入框中人名的信息
- 后台提交接口该如何设计
- 人名检索接口设计。用户键入关键字后,要通过搜索接口实时拉取人名,api接口该如何设计
- 如何检测@字符。如何区分是出默认人选还是按输入模糊检索。
- 选人后如何替换掉原来的检索文案。
- 选人浮层的定位。如何定位光标位置,如何将浮层放置在页面中最合适的位置;当浮层还没渲染时,如何知道浮层宽高从而进行位置选择
- 中文输入bug。中文输入时,编辑器内会先出现拼音,等用户按“空格”或“回车”后变成中文,应该如何处理这种场景
- 如何实现整个人名一次性删除
- 人名隔离问题。即如何确保输入人名之后,再输入其他字符时自动产生一个新的textNode类型的节点,而不是插入到人名标签中
- placeholder 怎样实现
- 粘贴操作的处理。当用户粘贴富文本时,我们要不允许其粘贴或者将其粘贴内容转成纯文本。
下面我们依次来说明如何基于 Web前端 来说实现一个具有完整at功能的输入框。
行业洞察
我们来瞅瞅行业里别人是咋做的。
新浪微博
微博是通过一个纯的 textarea 来实现的at功能。我们可以看到,他输入的人名就是一个纯字符串,且我们可以把光标放置到人名中间,甚至我们可以删掉人名中的某个字符,从而破坏掉人名:
因此,他的实现是 “仅仅将用户所选的用户id放到输入框对应位置”。他也无须对输入框中的人名建立一个用户信息的记录映射,因为他的场景比较特殊:它at出来的每个人名都是一个唯一id。所以,当他把输入框文本提交给后台时,也无须做任何处理,直接提交即可。后台可以根据字符串中的 “@” 和 “@后方的空格” 找出每个 “所谓的人名” 然后检索数据库找出其对应的用户信息。
通常这种at功能还存在一个“中文输入法”的坑,就是如果你输入中文,那么此时输入法会先将拼音放置到输入框,然后等你选词完毕后再把中文换到输入框,这会带来at功能的bug。我们看看新浪微博是咋处理的:在中文输入法输入时,新浪微博为了避免我们后文提到的诡异问题,它直接屏蔽掉了中文输入时的检索能力哈哈。如图,输入中文时并不会触发选人的搜索。
QQ空间
qq空间是用 contenteditable 的div来实现的。他把用户信息藏到了一个标签里。如图文本框中有2个同名的 catting,但他们分别是2个不同的用户。 由于空间是使用 contenteditable 实现的输入框,因此其人名通过 button 标签给包裹起来了,于是它把每个用户的id等信息藏到了button标签中。
QQ空间怎么处理中文输入法问题的呢,我们看看:
恩,他直接在有输入法弹窗的情况下,不允许你通过键盘操纵选人面板,也就不存在互相影响的问题了,也是机智。
再看看 QQ 空间的 placeholder 是怎么实现的呢?我们看看他的输入框dom:
发现这里有个div里放置了placeholder的内容,盖在输入框上面。该dom跟输入框div并列。当我们鼠标点击placeholder位置的时候,意味着我们打算开始输入,这时他把placeholder div隐藏,并主动聚焦到输入框的div里面,从而实现了placeholder效果。
由于我们的需求背景存在人名重复,因此无法采用新浪微博这种 “基于字符串解析反解” 的方案,而必须采用类似 QQ空间的 contenteditable 藏信息的方案。下面我们来分别看上文提到的各个技术点如何各个击破。
如何能反解出输入框中人名的信息
前文我们已经分析了能面向“人名重复”场景的解决方案,只有如下2个办法:
- 拦截用户的输入,搞一个
虚拟抽象层
,像我之前做的 小程序方案一样,维持一个特殊的内存结构。 - 使用 contenteditable 的富文本输入框作为输入区域。这样可以将每个人的用户信息藏到文本框里的人名标签里。例如使用span或button包裹人名。
第一种方案比较适合小程序场景下输入模式比较单一,只有虚拟键盘的输入;对于PC端用户输入模式多样的情况来说,实现难度极大。因此,我们采用方案2这种比较主流和简便的方案---把用户信息藏到标签里。
举个栗子:你在输入框中输入了 “你好 @小明”。 那么在 contenteditable 的输入框中其实是:
<div contenteditable=true>你好, <span data-info="{name: '小明', id: 1}">@小明</span></div>
这样,我们拿到输入框内部的innerHTML后,可以轻松的拿到每个at人名的个人信息。
于是我设计了一个 AtEditor 的Vue组件,其 template 部分是这样的:
<template>
<div ref="jsAtEditorElement">
<div
ref="jsEditorElement"
contenteditable="true"
:class="[placeholderClass]"
@keyup="onInputText"
@keydown="onInputKeyDown"
@blur="onCloseDialog"
@mouseup="doToggleDialog"
@paste="doOnPaste"
></div>
<div ref="jsDialogElement" @mousedown.stop.prevent="doNull" :class="[atDialogClass]" :style="[atDialogPos, {visibility: isShowDialog ? 'visible' : 'hidden'}]">
<ul class="persons-dialog-lists">
<li v-for="(item, index) in lists" :key="item.id" @mousedown="onSelectPerson(item)" :class="{'active-hover': activeIndex == index}">
<img :src="item.avatar>
<span class="name">{{(item.name)}}</span>
</li>
</ul>
<div class="line"></div>
</div>
</div>
</template>
其中jsEditorElement就是输入框,设置其contenteditable为ture,同时绑定好了各种事件进行后续的逻辑处理。
其中jsDialogElement就是选人的浮层,通过visibility样式控制他是否展示,通过atDialogPos样式控制他的fixed定位的top和left坐标。
后台提交接口设计
当用户输入完毕,点击提交或保存时,我们的需求通常需要把文本框内部的文本和人名提取出来,且按序提交给后端存储。例如你空间或微博发布的每一条说说都要存储到后台数据库。那么,这里的接口我们可以将输入框内容抽象成1种消息结构,且该结构有2种类型。
class Msg {
constructor(type, data) {
this.type = type; // type可以是at类型也可以是text类型
this.data = data
}
}
例如依然是 “你好,@小明” 这样的输入。那么我们提交给后台时,其数据结构是这样的:
var postMsgs = [
{
type: 'text',
data: '你好,'
},
{
type: 'at',
data: {
name: '小明',
id: 1
}
}
]
人名检索接口设计
这里,我们是采用了2个不同的接口来分别实现 “获取推荐的几个联系人” 和 “根据用户输入的内容来搜索匹配的联系人”。因此,我们有如下2个接口:
getDefaultUsers
@paramslimit: 限制最大返回的联系人数量
@return
persons: [ { name: '小明', id: 1 }, { name: '小红', id: 2 } ]
getSearchUsers
@paramslimit: 限制最大返回的联系人数量 keyword: 用户在@后方输入的用来搜索的文本
@return
persons: [ { name: '小明', id: 1 }, { name: '小红', id: 2 } ]
这里实际上也可以使用一个接口来实现。例如无论用户 “只输入了@”,还是输入了 "@小" 都通过 getSearchUsers来调用,即只输入@时的keyword是空。
at字符和输入的检测
其实这里也比较复杂,我们不止要对 @ 字符做出特殊的反应。我们有多种不同的输入都要做出特殊的反应,如:
- 当用户输入 @ 时,我们要调起默认选人
- 当用户输入@,且后方跟着大于等于1个字符时,要调起搜索选人
- 当用户鼠标点击到某个字符处,要判断光标前方字符是否有 @,且区分是调起默认选人还是搜素选人
- 当用户键盘键入 “上箭头” “下箭头” 时,要将行为拦截并转换成对选人浮层人选的上下切换
- 当用户输入“回车”时,要拦截并转换成对选人浮层人选的确认操作
- 当用户输入“ESC返回”时,要转成对选人浮层的关闭操作
如上这些,我们主要是监听用户的 “keydown” 和 “keyup” 事件来做的。
之所以有些在 keydown里做,有些在keyup里做,是因为在keyup发生的时候,用户的文字已经输入到文本框了,所以这个事件比较适合检测@字符。但是像有些按键我们是不期望他默认行为发生的,因此我需要在 keydown 的时候就拦截掉,例如回车、上下箭头。
先来看,键盘事件keydown我是如何拦截的:
// 键盘按下
onInputKeyDown(e) {
if (this.isShowDialog) {
if (e.code == 'ArrowDown' || e.code === 'ArrowUp') {
// 上下移动光标,用于调整 dialog 里的人
if (e.code == 'ArrowDown') {
this.activeIndex++
if (this.activeIndex === this.lists.length) {
this.activeIndex = this.lists.length - 1
}
}
if (e.code == 'ArrowUp') {
this.activeIndex--
if (this.activeIndex === -1) {
this.activeIndex = 0
}
}
preventAfterAction.call(this, e)
}
else if (e.code === 'Enter') {
// 如果有弹窗,则代表确认选人
this.isShowDialog = false
preventAfterAction.call(this, e)
this.selectPerson(this.lists[this.activeIndex])
}
else if (e.code === 'Escape') {
this.isShowDialog = false
preventAfterAction.call(this, e)
}
}
else {
// 回车给拦掉,什么都不做
if (e.code === 'Enter') {
preventAfterAction.call(this, e)
}
}
function preventAfterAction(e) {
e.preventDefault()
this.preventKeyUp = true
}
},
再来看 keyup 我是如何检测 @ 的:
// 按键弹起
onInputText(e) {
if (this.preventKeyUp) {
this.preventKeyUp = false
return
}
this.preventKeyUp = false
const el = e.currentTarget
// 这是输入了@,那就直接弹选人浮层
if (e.code == 'Digit2' && e.shiftKey) {
this.showDefaultDialog()
}
else {
// 这里是输入的不是@,但是可能前方有@,因此需要进行检测看看是否要展示选人浮层
this.doToggleDialog()
}
},
来看看 doToggleDialog:
doToggleDialog() {
const rangeInfo = this.getEditorRange()
if (!rangeInfo || !rangeInfo.range || !rangeInfo.selection) return
const curNode = rangeInfo.range.endContainer
if (!curNode || curNode.nodeName !== '#text') return
const searchStr = curNode.textContent.slice(0, rangeInfo.selection.focusOffset)
// 判断光标位置前方是否有at,只有一个at则展示默认dialog,除了at还有关键字则展示searchDialog
const keywords = (/@([^@]*)$/).exec(searchStr)
if (keywords && keywords.length >= 2) {
// 展示搜索选人
const key_words = keywords[1]
const allMathStr = keywords[0]
if (allMathStr === '@') {
this.showDefaultDialog()
}
else {
this.showSearchDialog(key_words)
}
// 重点:记下弹窗前光标位置range
this.editorRange = rangeInfo
}
else {
// 关掉选人
this.closeDialog()
}
},
这里其实就是检测本次输入完字符后,光标前方是否有@,如果只有一个@就展示默认推荐人,如果是 "@xxx" 这样的结构,则用xxx去检索推荐人。
showSearchDilaog的代码我就不贴了,其实就是查询接口,拿到返回的人选列表,放到当前 Vue 的lists字段上。这里贴一下 getEditorRange的代码,他是用户获取当前光标选取的信息(即在弹出选人之前,把输入框中此刻的光标位置先记下来)
getEditorRange() {
let range = null;
let selection = null;
if (window.getSelection) {
selection = window.getSelection();
if (selection.getRangeAt && selection.rangeCount) {
range = selection.getRangeAt(0);
return {
range,
selection,
};
} else {
return null;
}
} else {
return null;
}
},
选人浮层的定位。
上文解决了浮层何时弹,现在就要解决浮层要弹在哪里的问题。我们需要在浮层isSHow设置为 true之前,就要算出把浮层放在哪里。这里我们首先要解决俩问题:
- 如何在浮层渲染之前知道当前浮层的宽高。因为我们要计算浮层边缘跟整个window窗口的边界,从而找到最合适的放置位置。
- 如何知道当前光标的位置。因为我们的浮层必然是像一个气泡一样,以光标位置为起点展示的。
对于第一个问题,基于 vue 的 nextTick 来实现即可,因为 nextTick是在vue的异步dom更新队列之后执行的,此时dom已经更新(同时我们的dialog是基于visibility实现隐藏的,因此其宽高我们实际上是能拿到的)。
对于第二个问题,业界通常是使用一个隐藏标签来重放输入框内容从而计算位置。我们这里直接使用了一个现成的类库:http://ichord.github.io/Caret...
以下是 _showDialog 函数的具体实现:
_showDialog() {
this.isShowDialog = false // 先隐藏
this.$nextTick(() => {
// 等隐藏完毕,且最新lists数据dom生成后,可基于此时的dom去获取最新的dialog宽高和坐标
const el = this.$refs['jsEditorElement']
const jsDialogElement = this.$refs['jsDialogElement']
if (!el) return
const caret = $(el).caret('offset')
var realPosition = this._getAdapterPosition(caret, jsDialogElement) // 获取正确的放置坐标,防止超出边界。本文基于offset
this.atDialogPos = {
left: realPosition.left + 'px',
top: realPosition.top + 'px'
}
this.activeIndex = 0
this.isShowDialog = true // 此时再展示出来
})
},
接下来的问题:如何将浮层放置在页面中最合适的位置。前文我们已经说了这个放置的大致最佳策略。即:
- 左上、左下、右上、右下每个方向都可以放置浮层
- 如果任何一个位置空间不够,则换一个位置放置。
- 其放置也是有优先级的,例如右下方和右上方同时都能放下,则右下方优先级更高。其优先级顺序应该是:右下方、右上方、左下方、左上方。
我是4个方向依次试探来实现的:
_getAdapterPosition(caret, dialogElem) {
const fixHeightGap = 5;
caret.top = caret.top - fixHeightGap
caret.height = caret.height + fixHeightGap + 5
const clientWidth = document.scrollingElement.clientWidth
const clientHeight = document.scrollingElement.clientHeight
const toClientLeft = caret.left
const toClientTop = (caret.top - document.scrollingElement.scrollTop)
const toClientRight = clientWidth - toClientLeft
const toClientBottom = clientHeight - toClientTop
const dialogSize = {
height: dialogElem.offsetHeight,
width: dialogElem.offsetWidth
}
// 看是否可放右侧下方 (原点要从光标下方开始算起, 因此toClientBottom要加一下光标高度)
if (((toClientBottom - caret.height) >= dialogSize.height) && (toClientRight >= dialogSize.width)) {
return {
top: toClientTop + caret.height,
left: toClientLeft
}
}
else if ((toClientTop >= dialogSize.height) && (toClientRight >= dialogSize.width)) {
return {
top: toClientTop - dialogSize.height,
left: toClientLeft
}
}
else if (((toClientBottom - caret.height) >= dialogSize.height) && (toClientLeft >= dialogSize.width)) {
return {
top: toClientTop + caret.height,
left: toClientLeft - dialogSize.width
}
}
else if ((toClientTop >= dialogSize.height) && (toClientLeft >= dialogSize.width)) {
return {
top: toClientTop - dialogSize.height,
left: toClientLeft - dialogSize.width
}
}
else {
let left = toClientLeft > toClientRight ? toClientLeft - dialogSize.width : toClientLeft
let top = toClientTop > toClientBottom ? toClientTop - dialogSize.height : toClientTop + caret.height
return {
top,
left
}
}
return caret
},
如何实现整个人名一次性删除
这里可以很复杂,也可以很简单。最简单的方案就是业界通常使用的 button 标签,尤其是 chrome 内核的浏览器,button标签方案是比较兼容的。我们可以将人名对应的用户信息 塞到 button 标签当中。但为了能在退格的时候将他整体删除且不能随意修改,我们通常对 button 标签再加点料:
const btn = document.createElement('button')
btn.dataset['person'] = JSON.stringify(person)
btn.textContent = `@${person.name}`
btn.contentEditable = false
btn.addEventListener('click', () => {
return false
}, false)
btn.tabindex = '-1'
选人后如何替换掉原来的检索文案。
在弹出选人浮层的时候onToggleDialog中 ,我们有个关键的代码,就是:
// 重点:记下弹窗前光标位置range
this.editorRange = rangeInfo
由于我们之前的光标已经记下来了,因此,当选人确认后,我们要做的:就是把之前光标位置到前方@字符的内容delete删除,然后换成我们选择的人名(即我们创建的button标签)。代码如下:
selectPerson(person) {
person.corp_id = person.corp_id || window.corp_id;
this.isShowDialog = false
const editor = this.$refs['jsEditorElement']
if (editor) {
editor.focus()
// 删掉草稿start
const editorRange = this.editorRange.range
if (!editorRange) return
const textNode = editorRange.endContainer // 拿到末尾文本节点
const endOffset = editorRange.endOffset // 光标位置
// 找出光标前的at符号位置
const textNodeValue = textNode.nodeValue
const expRes = (/@([^@]*)$/).exec(textNodeValue)
if (expRes && expRes.length > 1) {
editorRange.setStart(textNode, expRes.index)
editorRange.setEnd(textNode, endOffset)
// console.log('要插入at的位置range', editorRange, editorRange.startOffset, editorRange.endOffset)
editorRange.deleteContents() // 删除草稿end
// console.log('delete后的range', editorRange, editorRange.startOffset, editorRange.endOffset)
// return
const btn = document.createElement('button')
btn.dataset['person'] = JSON.stringify(person)
btn.textContent = `@${person.name}`
btn.contentEditable = false
btn.addEventListener('click', () => {
return false
}, false)
btn.tabindex = '-1'
const bSpaceNode = document.createTextNode('\u200b') // 不可见字符,为了放光标方便
this.insertHtmlAtCaret([btn, bSpaceNode], this.editorRange.selection, this.editorRange.range)
}
}
},
人名隔离问题
默认情况下,当我们的button添加到输入框之后,如果我们在 button 后方继续输入普通文本,那么文本可能会跑到button标签内部。
例如,当你选择了小明之后,输入框中如果是这样的结构:
<div contenteditalbe=true>你好,<button>小明</button></div>
当你在后方再输入:“一起吃饭”。那么,结构可能会变成:
<div contenteditalbe=true>你好,<button>小明一起吃饭把</button></div>
新字符跑到了button标签内部,显然不符合我们的预期。因此,这里我们必须通过hack手段来解决。如何确保输入人名之后,再输入其他字符时自动产生一个新的textNode类型的节点,而不是插入到人名标签中呢?
大家可能注意到上一小节代码中,我们给 insertHtmlAtCaret 函数传递元素时,除了传递button,还传递了一个自己创建的 textNode节点:
const bSpaceNode = document.createTextNode('\u200b') // 不可见字符,为了放光标方便
这个字符是一个不可见的字符,但他的确是一个字符。有了这么一个字符在 button 的后方,则我们新添加字符的话,他就是在 \u200b位置往后添加,这样就可以实现我们期望的效果了。 关于插入什么字符,业界通常也会使用空格字符来实现,例如QQ空间便是使用空格字符。
而我这里为了不让领宽字符提交到后台,所以在提交之前做了下检测:
// 从dom中拿出带有at信息的结构 (对于at是用button包裹,就把button里的date-person属性拿出来)
getMsgStructure: function(elem) {
elem = elem[0]
var res = []
var self = this
Array.from(elem.childNodes).forEach(function(child) {
if (child.nodeName === '#text') {
var str = child.nodeValue
if (str && str.length > 0) {
var lastChar = str[str.length - 1]
if (lastChar.charCodeAt(0) === 0x200b) {
// 零宽字符去掉
str = str.slice(0, -1)
}
}
console.log('str', str)
if (str) {
res.push({type: 'text', data: str})
}
} else if (child.nodeName === 'BR') {
res.push({type: 'text', data: '\n'})
} else if (child.nodeName === 'BUTTON') {
res.push({type: 'at', data: JSON.parse(child.dataset.person)})
}
else if (child.nodeName === 'SPAN') {
res.push({type: 'text', data: child.textContent})
}
})
res = self._defragmentation(res)
return res
},
数据碎片问题
什么是数据碎片问题呢。举个栗子:
例如:用户输入了 "你好,@小明,吃了吗" 这样一段话,当用户把“@小明” 删掉后,文本变成 “你好,吃了吗”,但实际上底层 contenteditable 文本域里面是2个 textNode:“你好,” 和 “吃了吗”。 虽然对于整体功能来说没什么大碍,但是不利于后端减少数据结构的冗余。
那么这里的解决办法也比较容易,就是提交给后台之前,对同类型的 textNode 节点进行合并,减少碎片。这里简单实现了一下这个合并算法:
_defragmentation(msgs) {
// msgs 就是上文说说的消息数据结构
const newMsgs = [];
msgs.forEach(msg => {
const last = newMsgs[newMsgs.length - 1];
if (last && last.type && last.type === 'text' && msg.type === 'text') {
last.data = last.data + msg.data
}
else {
if (msg && msg.type === 'text' && msg.renderLength == 0) return;
newMsgs.push(msg);
}
});
return newMsgs
},
中文输入bug
中文输入时,编辑器内会先出现拼音,此时会触发输入框的 keydown,keyup等事件,从而导致我们选人浮层开始出现。可是当用户按下 回车键,我们的选人逻辑会将选中的人名放置到输入框,而中文输入法会将他的候选词放入输入框,这里会导致“选的人”和“选的词”同时出现到输入框里。
应该如何处理这种场景呢?我采用的办法是,检测到中文输入选词完毕后,我们及时更新我vue组件内的光标选取变量,从而让选人事件发生后,可以正确的删除前方的文案。
例如:当用户输入 "@x",此时他输入法中出现了 “小,晓,笑” 等候选词,同时此时我们的选人浮层也出现了。
当他按下回车,此时输入法会立刻将“小”放置到输入框,从而输入框内容变成 "@小"。而此时,我需要立刻将我 Vue组件的 editorRange 变量更新,记录下此时光标位于 “@小” 后方。
那么,接下来 回车会触发我的选人确认逻辑,此时我的逻辑会将光标位置开始到前方的@位置所有字符进行删除替换,因此 "@小"就变成了 “@小明”。
做法:给输入框增加2个事件
"@compositionstart="onCompositionStart"
@compositionend="onCompositionEnd"
方法实现:
onCompositionStart() {
console.log('正在输入中文');
},
onCompositionEnd(e) {
if (this.isShowDialog) {
// 重置光标位置,因为此时中文会填进去。。
this.editorRange = this.getEditorRange();
}
},
placeholder 的实现
这里,我采用了 css 的简便方案,即通过 empty 伪类选择器,检测到 div 中内容为空时,我们往输入框的 after 伪类下添加一个 文本;当光标focus到输入框的时候,我们使用 focus 伪类再清掉 after元素的内容。从而实现 placeholder 的效果。
.at-editor-placeholder-cn:empty:before {
content: '填写内容,输入@以选择某人';
color: gray;
}
.at-editor-placeholder-cn:focus:before {
content: none;
}
粘贴的处理
当用户进行“粘贴”,我们要拦截粘贴动作,并取其粘贴板上的文本内容,手工放置到光标位置。这里我在 onDoPaste 函数中实现:
doOnPaste(e) {
var pastedText = undefined;
if (window.clipboardData && window.clipboardData.getData) { // IE
pastedText = window.clipboardData.getData('Text');
} else if (e.clipboardData && e.clipboardData.getData) {
pastedText = e.clipboardData.getData('text/plain');
}
// 放到光标位置
const rangeInfo = this.getEditorRange()
if (rangeInfo && pastedText) {
rangeInfo.range.deleteContents()
const newTextNode = document.createTextNode(pastedText)
this.insertHtmlAtCaret(newTextNode, rangeInfo.selection, rangeInfo.range)
}
e.preventDefault()
return false;
},
这里最主要是这一句:
e.clipboardData.getData('text/plain');
帮助我们拿到所有dom中的纯文本部分。
总结
至此,我们实现了一个 “看起来简单” 但 “要考虑的点特别多” 的输入框 at 功能。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。