13

@功能,是一个“看起来简单”,实际上“要考虑特别多问题” 的功能需求。本文对这个常见的功能需求的实现进行了记录。

之前我在小程序里在没有 contenteditable 的input中实现了一个输入 @ 字符实现 at 选人功能的需求。无米之炊: 小程序内实现一个具有“@功能at功能”的输入框。当时由于小程序缺少富文本输入框,因此只能在纯 input 里面实现人名输入和用户信息还原。

如今咱们就要来实现 web 的版本了。web 端具有更加丰富的 api 给我们使用,但是其考虑的问题也会更多,因为web端用户的输入变换莫测(即用户可以使用鼠标、键盘或其他输入设备随意的选择、粘贴、控制文本框)。因此,web端如果采用拦截用户所有输入情况进行 "虚拟抽象层" 处理和渲染会有些力不从心。

本文最终还是采用通用的方案---contenteditable来实现,下面介绍其过程和思路。

明确需求

要做这个需求,我们需要首先明确 at功能需求 其本质上要包含哪些必要的功能以及一些特定的前置条件,这会直接影响到我们技术方案的设计。

前置条件明确

我们需要向产品确认联系人中是否有 “重名” 的联系人,如联系人中是否会有 2 个叫做 “小明” 的联系人。

如果不会重名,我们便可以使用一种 “类似新浪微博” 的方案,即无需管理和记录每个人的真实id,只需让用户在文本框内随意选择和输入文本和人名,等到最后用户点击保存提交微博时,将文本框内的字符串依次遍历反解(反解办法就是,先找到一个@字符作为开始,然后直到碰到一个空格作为结束,看一下该字符串是否等于某个人名账号)。

由于新浪微博他的人名(账户)是唯一的,因此他便采用了如上简便的做法。从我们随意输入和粘贴一段文本之后产生的bug来看,的确如此:

image.png

以上是我通过鼠标@或者文本输入插入了一些 齐鲁晚报 的人名。但是当我主动把其中某些字符删掉,或者把 "@齐鲁晚报" 后方的空格干掉,提交后微博便无法还原我at的人了。图上每个人名我都无法hover或点击(因为我破坏了人名反解的结构)。

但是,如果我从其他地方拷贝一个 "@齐鲁晚报" 的字符串,然后在其后方敲入一个空格,再提交,则微博可以获取到该用户。

image.png

因此,我们猜到新浪微博反解人名的办法是:寻找@字符及其后方的空格作为一个人名。我们查看新浪微博dom中是通过textarea实现的输入框,通过其 api限制以及上面的效果来看,其原理就是如我们所猜测的一样:基于输入内容的字符串进行反解而已,思路比较简单。

而本文我所面向的需求前置条件是:联系人具有可重复的人名。此时的技术方案便面临一个最大的问题:如何能反解出输入框中人名的信息?举例来说:假如我们输入框中的字符串内容是 "你好,@小明,我们一起吃饭吧,你叫上另外一个 @小明"。 那么,提交给后台存储时,我们比如告知后台第一个小明是1号小明,第二个小明是2号小明。如何做到呢?这个我们在下文讲述。

需求定义

OK,我们来基于 “人名可以重复” 这个前置条件,来定义一下我们的需求。

  • 用户可以在文本框中输入任意的普通字符
  • 用户可以通过输入一个 "@" 字符来调起一个选人浮层,选人浮层的位置必须放置在光标所在的位置,且能够根据窗口边缘进行适当的位置调整。例如下方空间不够时,则放到上方。我们来看看新浪微博:
    image.png
    这个是默认下方空间够用的时候。当下方不够时,浮层会出现在上方:
    image.png
    但是,经过测试,当右侧空间不够时,它并没有出现在左方:
    image.png
    理论上,最好的方案应该是:左上、左下、右上、右下每个方向都可以放置浮层,如果任何一个位置空间不够,则换一个位置放置。其放置也是有优先级的,例如右下方和右上方同时都能放下,则右下方优先级更高。其优先级顺序应该是:右下方、右上方、左下方、左上方。
  • 当选人浮层出现后,用户可以通过键盘的“上”、“下”来操纵选人浮层上对应的人选,并可以通过“回车键”将人选填入到输入框中。当然,也可以支持鼠标选择。
  • 输入框中的人名是一个整体。即:当用户鼠标在输入框中人名附近左右移动时,光标不能放置到人名中间。同时,用户在人名后方按下“退格”键,则会把当前人名整体瞬间删除。
  • 支持模糊搜索。即:当用户输入 @ 时,弹出推荐的联系人,当他输入 "@小",则浮层里要把 "小明" "小红" "小x" 等搜索后的联系人。 且选人完毕后,要把选中的如 "小红" 填入到文本框中
  • 用户输入完毕后,可以把文本框中所有字符串或人名转成对应的结构存储到后端存储。从而在其他页面可以展示。例如QQ空间发表说说后,后台需要给你输入的人名发送at通知,而且要在你的qq说说页面展示你发表的内容(当你鼠标放到人名上还可以展示用户卡片),如图:
    image.png

技术上要考虑的点:

需求看起来简单,实际上这个功能是个前端大坑。我们看看这涉及到多少个要考虑的点,每个点都不是那么简单:

  • 如何能反解出输入框中人名的信息
  • 后台提交接口该如何设计
  • 人名检索接口设计。用户键入关键字后,要通过搜索接口实时拉取人名,api接口该如何设计
  • 如何检测@字符。如何区分是出默认人选还是按输入模糊检索。
  • 选人后如何替换掉原来的检索文案。
  • 选人浮层的定位。如何定位光标位置,如何将浮层放置在页面中最合适的位置;当浮层还没渲染时,如何知道浮层宽高从而进行位置选择
  • 中文输入bug。中文输入时,编辑器内会先出现拼音,等用户按“空格”或“回车”后变成中文,应该如何处理这种场景
  • 如何实现整个人名一次性删除
  • 人名隔离问题。即如何确保输入人名之后,再输入其他字符时自动产生一个新的textNode类型的节点,而不是插入到人名标签中
  • placeholder 怎样实现
  • 粘贴操作的处理。当用户粘贴富文本时,我们要不允许其粘贴或者将其粘贴内容转成纯文本。

下面我们依次来说明如何基于 Web前端 来说实现一个具有完整at功能的输入框。

行业洞察

我们来瞅瞅行业里别人是咋做的。

新浪微博

微博是通过一个纯的 textarea 来实现的at功能。我们可以看到,他输入的人名就是一个纯字符串,且我们可以把光标放置到人名中间,甚至我们可以删掉人名中的某个字符,从而破坏掉人名:
image.png

因此,他的实现是 “仅仅将用户所选的用户id放到输入框对应位置”。他也无须对输入框中的人名建立一个用户信息的记录映射,因为他的场景比较特殊:它at出来的每个人名都是一个唯一id。所以,当他把输入框文本提交给后台时,也无须做任何处理,直接提交即可。后台可以根据字符串中的 “@” 和 “@后方的空格” 找出每个 “所谓的人名” 然后检索数据库找出其对应的用户信息。

通常这种at功能还存在一个“中文输入法”的坑,就是如果你输入中文,那么此时输入法会先将拼音放置到输入框,然后等你选词完毕后再把中文换到输入框,这会带来at功能的bug。我们看看新浪微博是咋处理的:在中文输入法输入时,新浪微博为了避免我们后文提到的诡异问题,它直接屏蔽掉了中文输入时的检索能力哈哈。如图,输入中文时并不会触发选人的搜索。
image.png

QQ空间

qq空间是用 contenteditable 的div来实现的。他把用户信息藏到了一个标签里。如图文本框中有2个同名的 catting,但他们分别是2个不同的用户。 由于空间是使用 contenteditable 实现的输入框,因此其人名通过 button 标签给包裹起来了,于是它把每个用户的id等信息藏到了button标签中。
image.png

image.png

QQ空间怎么处理中文输入法问题的呢,我们看看:
image.png

恩,他直接在有输入法弹窗的情况下,不允许你通过键盘操纵选人面板,也就不存在互相影响的问题了,也是机智。

再看看 QQ 空间的 placeholder 是怎么实现的呢?我们看看他的输入框dom:
image.png
发现这里有个div里放置了placeholder的内容,盖在输入框上面。该dom跟输入框div并列。当我们鼠标点击placeholder位置的时候,意味着我们打算开始输入,这时他把placeholder div隐藏,并主动聚焦到输入框的div里面,从而实现了placeholder效果。

由于我们的需求背景存在人名重复,因此无法采用新浪微博这种 “基于字符串解析反解” 的方案,而必须采用类似 QQ空间的 contenteditable 藏信息的方案。下面我们来分别看上文提到的各个技术点如何各个击破。

如何能反解出输入框中人名的信息

前文我们已经分析了能面向“人名重复”场景的解决方案,只有如下2个办法:

  1. 拦截用户的输入,搞一个 虚拟抽象层,像我之前做的 小程序方案一样,维持一个特殊的内存结构。
  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
    @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我是如何拦截的:

        // 键盘按下
        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 功能。


sheldon
947 声望1.6k 粉丝

echo sheldoncui


引用和评论

0 条评论