editable string 转_基于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 的输入框中其实是:

你好, @小明

这样,我们拿到输入框内部的innerHTML后,可以轻松的拿到每个at人名的个人信息。

于是我设计了一个 AtEditor 的Vue组件,其 template 部分是这样的:

ref="jsEditorElement"

contenteditable="true"

:class="[placeholderClass]"

@keyup="onInputText"

@keydown="onInputKeyDown"

@blur="onCloseDialog"

@mouseup="doToggleDialog"

@paste="doOnPaste"

>

  • {{(item.name)}}

其中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: 限制最大返回的联系人数量

@returnpersons: [

{ name: '小明', id: 1 },

{ name: '小红', id: 2 }

]

getSearchUsers

@paramslimit: 限制最大返回的联系人数量

keyword: 用户在@后方输入的用来搜索的文本

@returnpersons: [

{ 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标签内部。

例如,当你选择了小明之后,输入框中如果是这样的结构:

你好,小明

当你在后方再输入:“一起吃饭”。那么,结构可能会变成:

你好,小明一起吃饭把

新字符跑到了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"

@compositiοnend="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 功能。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
©️2022 CSDN 皮肤主题:数字20 设计师:CSDN官方博客 返回首页
评论

打赏作者

一二三是五六十mkq

你的鼓励将是我创作的最大动力

¥2 ¥4 ¥6 ¥10 ¥20
输入1-500的整数
余额支付 (余额:-- )
扫码支付
扫码支付:¥2
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值