基于contenteditable技术实现@选人功能框
@功能,是一个“看起来简单”,实际上“要考虑特别多问题” 的功能需求。本文对这个常见的功能需求的实现进行了记录。
之前我在小程序里在没有 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 的输入框中其实是:
1 | <div contenteditable="true"> |
这样,我们拿到输入框内部的 innerHTML 后,可以轻松的拿到每个 at 人名的个人信息。
于是我设计了一个 AtEditor 的 Vue 组件,其 template 部分是这样的:
1 | <template> |
其中 jsEditorElement 就是输入框,设置其 contenteditable 为 ture,同时绑定好了各种事件进行后续的逻辑处理。
其中 jsDialogElement 就是选人的浮层,通过 visibility 样式控制他是否展示,通过 atDialogPos 样式控制他的 fixed 定位的 top 和 left 坐标。
后台提交接口设计
当用户输入完毕,点击提交或保存时,我们的需求通常需要把文本框内部的文本和人名提取出来,且按序提交给后端存储。例如你空间或微博发布的每一条说说都要存储到后台数据库。那么,这里的接口我们可以将输入框内容抽象成 1 种消息结构,且该结构有 2 种类型。
1 | class Msg { |
例如依然是 “你好,@小明” 这样的输入。那么我们提交给后台时,其数据结构是这样的:
1 | var postMsgs = [ |
人名检索接口设计
这里,我们是采用了 2 个不同的接口来分别实现 “获取推荐的几个联系人” 和 “根据用户输入的内容来搜索匹配的联系人”。因此,我们有如下 2 个接口:
- getDefaultUsers
@params
limit: 限制最大返回的联系人数量
@return
persons: [
{ name: ‘小明’, id: 1 },
{ name: ‘小红’, id: 2 }
] - getSearchUsers
@params
limit: 限制最大返回的联系人数量
keyword: 用户在@后方输入的用来搜索的文本
@return
persons: [
{ name: ‘小明’, id: 1 },
{ name: ‘小红’, id: 2 }
]
这里实际上也可以使用一个接口来实现。例如无论用户 “只输入了@”,还是输入了 “@小” 都通过 getSearchUsers 来调用,即只输入@时的 keyword 是空。
at 字符和输入的检测
其实这里也比较复杂,我们不止要对 @ 字符做出特殊的反应。我们有多种不同的输入都要做出特殊的反应,如:
- 当用户输入 @ 时,我们要调起默认选人
- 当用户输入@,且后方跟着大于等于 1 个字符时,要调起搜索选人
- 当用户鼠标点击到某个字符处,要判断光标前方字符是否有 @,且区分是调起默认选人还是搜素选人
- 当用户键盘键入 “上箭头” “下箭头” 时,要将行为拦截并转换成对选人浮层人选的上下切换
- 当用户输入“回车”时,要拦截并转换成对选人浮层人选的确认操作
- 当用户输入“ESC 返回”时,要转成对选人浮层的关闭操作
如上这些,我们主要是监听用户的 “keydown” 和 “keyup” 事件来做的。
之所以有些在 keydown 里做,有些在 keyup 里做,是因为在 keyup 发生的时候,用户的文字已经输入到文本框了,所以这个事件比较适合检测@字符。但是像有些按键我们是不期望他默认行为发生的,因此我需要在 keydown 的时候就拦截掉,例如回车、上下箭头。
先来看,键盘事件 keydown 我是如何拦截的:
1 | // 键盘按下 |
再来看 keyup 我是如何检测 @ 的:
1 |
|
来看看 doToggleDialog:
1 | doToggleDialog() { |
这里其实就是检测本次输入完字符后,光标前方是否有@,如果只有一个@就展示默认推荐人,如果是 “@xxx” 这样的结构,则用 xxx 去检索推荐人。
showSearchDilaog 的代码我就不贴了,其实就是查询接口,拿到返回的人选列表,放到当前 Vue 的 lists 字段上。这里贴一下 getEditorRange 的代码,他是用户获取当前光标选取的信息(即在弹出选人之前,把输入框中此刻的光标位置先记下来)
1 | getEditorRange() { |
选人浮层的定位。
上文解决了浮层何时弹,现在就要解决浮层要弹在哪里的问题。我们需要在浮层 isSHow 设置为 true 之前,就要算出把浮层放在哪里。这里我们首先要解决俩问题:
- 如何在浮层渲染之前知道当前浮层的宽高。因为我们要计算浮层边缘跟整个 window 窗口的边界,从而找到最合适的放置位置。
- 如何知道当前光标的位置。因为我们的浮层必然是像一个气泡一样,以光标位置为起点展示的。
对于第一个问题,基于 vue 的 nextTick 来实现即可,因为 nextTick 是在 vue 的异步 dom 更新队列之后执行的,此时 dom 已经更新(同时我们的 dialog 是基于 visibility 实现隐藏的,因此其宽高我们实际上是能拿到的)。
对于第二个问题,业界通常是使用一个隐藏标签来重放输入框内容从而计算位置。我们这里直接使用了一个现成的类库:http://ichord.github.io/Caret.js/
以下是 _showDialog 函数的具体实现:
1 |
|
接下来的问题:如何将浮层放置在页面中最合适的位置。前文我们已经说了这个放置的大致最佳策略。即:
- 左上、左下、右上、右下每个方向都可以放置浮层
- 如果任何一个位置空间不够,则换一个位置放置。
- 其放置也是有优先级的,例如右下方和右上方同时都能放下,则右下方优先级更高。其优先级顺序应该是:右下方、右上方、左下方、左上方。
我是 4 个方向依次试探来实现的:
1 | _getAdapterPosition(caret, dialogElem) { |
如何实现整个人名一次性删除
这里可以很复杂,也可以很简单。最简单的方案就是业界通常使用的 button 标签,尤其是 chrome 内核的浏览器,button 标签方案是比较兼容的。我们可以将人名对应的用户信息 塞到 button 标签当中。但为了能在退格的时候将他整体删除且不能随意修改,我们通常对 button 标签再加点料:
1 | const btn = document.createElement("button"); |
选人后如何替换掉原来的检索文案。
在弹出选人浮层的时候 onToggleDialog 中 ,我们有个关键的代码,就是:
1 | // 重点:记下弹窗前光标位置range |
由于我们之前的光标已经记下来了,因此,当选人确认后,我们要做的:就是把之前光标位置到前方@字符的内容 delete 删除,然后换成我们选择的人名(即我们创建的 button 标签)。代码如下:
1 | selectPerson(person) { |
人名隔离问题
默认情况下,当我们的 button 添加到输入框之后,如果我们在 button 后方继续输入普通文本,那么文本可能会跑到 button 标签内部。
例如,当你选择了小明之后,输入框中如果是这样的结构:
1 | <div contenteditalbe="true">你好,<button>小明</button></div> |
当你在后方再输入:“一起吃饭”。那么,结构可能会变成:
1 | <div contenteditalbe="true">你好,<button>小明一起吃饭把</button></div> |
新字符跑到了 button 标签内部,显然不符合我们的预期。因此,这里我们必须通过 hack 手段来解决。如何确保输入人名之后,再输入其他字符时自动产生一个新的 textNode 类型的节点,而不是插入到人名标签中呢?
大家可能注意到上一小节代码中,我们给 insertHtmlAtCaret 函数传递元素时,除了传递 button,还传递了一个自己创建的 textNode 节点:
1 | const bSpaceNode = document.createTextNode("\u200b"); // 不可见字符,为了放光标方便 |
这个字符是一个不可见的字符,但他的确是一个字符。有了这么一个字符在 button 的后方,则我们新添加字符的话,他就是在 \u200b 位置往后添加,这样就可以实现我们期望的效果了。 关于插入什么字符,业界通常也会使用空格字符来实现,例如 QQ 空间便是使用空格字符。
而我这里为了不让领宽字符提交到后台,所以在提交之前做了下检测:
1 | // 从dom中拿出带有at信息的结构 (对于at是用button包裹,就把button里的date-person属性拿出来) |
数据碎片问题
什么是数据碎片问题呢。举个栗子:
例如:用户输入了 “你好,@小明,吃了吗” 这样一段话,当用户把“@小明” 删掉后,文本变成 “你好,吃了吗”,但实际上底层 contenteditable 文本域里面是 2 个 textNode:“你好,” 和 “吃了吗”。 虽然对于整体功能来说没什么大碍,但是不利于后端减少数据结构的冗余。
那么这里的解决办法也比较容易,就是提交给后台之前,对同类型的 textNode 节点进行合并,减少碎片。这里简单实现了一下这个合并算法:
1 | _defragmentation(msgs) { |
中文输入 bug
中文输入时,编辑器内会先出现拼音,此时会触发输入框的 keydown,keyup 等事件,从而导致我们选人浮层开始出现。可是当用户按下 回车键,我们的选人逻辑会将选中的人名放置到输入框,而中文输入法会将他的候选词放入输入框,这里会导致“选的人”和“选的词”同时出现到输入框里。
应该如何处理这种场景呢?我采用的办法是,检测到中文输入选词完毕后,我们及时更新我 vue 组件内的光标选取变量,从而让选人事件发生后,可以正确的删除前方的文案。
例如:当用户输入 “@x”,此时他输入法中出现了 “小,晓,笑” 等候选词,同时此时我们的选人浮层也出现了。
当他按下回车,此时输入法会立刻将“小”放置到输入框,从而输入框内容变成 “@小”。而此时,我需要立刻将我 Vue 组件的 editorRange 变量更新,记录下此时光标位于 “@小” 后方。
那么,接下来 回车会触发我的选人确认逻辑,此时我的逻辑会将光标位置开始到前方的@位置所有字符进行删除替换,因此 “@小”就变成了 “@小明”。
做法:给输入框增加 2 个事件
1 | "@compositionstart="onCompositionStart" |
方法实现:
1 | onCompositionStart() { |
placeholder 的实现
这里,我采用了 css 的简便方案,即通过 empty 伪类选择器,检测到 div 中内容为空时,我们往输入框的 after 伪类下添加一个 文本;当光标 focus 到输入框的时候,我们使用 focus 伪类再清掉 after 元素的内容。从而实现 placeholder 的效果。
1 | .at-editor-placeholder-cn:empty:before { |
粘贴的处理
当用户进行“粘贴”,我们要拦截粘贴动作,并取其粘贴板上的文本内容,手工放置到光标位置。这里我在 onDoPaste 函数中实现:
1 |
|
这里最主要是这一句:
1 | e.clipboardData.getData('text/plain'); |
帮助我们拿到所有 dom 中的纯文本部分。
总结
至此,我们实现了一个 “看起来简单” 但 “要考虑的点特别多” 的输入框 at 功能。