无米之炊: 小程序内实现一个具有“@功能at功能”的输入框

什么是at功能

所谓的at功能,就是指的在聊天框中输入人的姓名等信息时,允许用户在输入"@"字符之后,可以调起一个选人控件,方便用户快速输入人名。

例如:微博输入框,QQ空间的说说输入框。我们可以在一个输入框内输入 "@" 字符,然后会调起一个选人浮层或全屏选人控件(在桌面端通常是个浮层,在移动端通常是一个全屏控件)。

at功能的需求差异

在接到 at 功能的需求时,我们需要首先确定一个问题:即我们at出来的人名,是否有重名的现象。即:在输入框中同时出现 "@abc" 和 "@abc" 两个人名时,这俩人是否必然代表同一个人。

像新浪微博的场景下,其at出来的微博账户,必然是唯一的,因此其技术实现方案便可简化为:只需将用户从选人控件中选择的人渲染到输入框即可

而像QQ空间等需求场景下,我们at选出来的一个用户昵称,实际上是可以重名的,这时,我们的技术方案必须考虑到:如何将一个输入框中的人名要跟他对应的账户信息一一映射起来。只有这样,当我们把用户输入的消息保存给后台时,才能清晰的还原出两个"@abc"分别是哪个人。

如下是qq空间输入框,我可以输入2个同名的人,他分别可以给我两个不同的好友发送at消息:

image.png

本文,我所讨论的是QQ空间这种可重名的场景。因此,我们的技术方案需要考虑如何将at人名与账户信息记录映射起来。

小程序: 难为无米之炊

在 web 端,通常我们使用 div 配合 contentediable,另外再配合 Range和Selection的光标控制 api 来实现类似聊天框里的 at 功能。

其中:

contenteditable api允许我们将html标签插入到编辑器当中,这样我们便可以将账户信息“塞到”标签里,从而在提交后台时,从标签里把账户信息还原出来 range api提供了控制光标选取和设置选取内容的能力,这允许我们在用户选完控件人名后,我们将at字符删除,并把新选择的人名标签渲染到输入框里。

可是 小程序的输入框input和textarea并没有 web 那么多强大的 API (比如 Range和Selection),也无法在小程序内使用 contenteditable实现富文本。小程序仅有一个 bindinput 事件:

image.png

其事件返回3个参数:

value: 当前输入框中最新的值。 cursor: 当前光标的位置 keyCode:当前输入事件的键盘按键

思路

我们要在一个如此 朴素 的输入框,利用仅有的 value\cursor\keyCode 3个参数实现 "at检测"、"人名渲染"、"删除检测"、"重名支持(即账户信息还原)"。 其最大的难点主要还是如何记录账户信息从而支持重名。

能想到的方案有3种:

每当输入at字符并选择了人名后,我们在另外一个数据结构中记录该账户信息。例如维持一个 persons: [] 数组。 但是我们要在用户对输入框内容进行 增删改查 的同时对我们的persons结构进行同步增删改查的更新,其修改难度会很复杂。 我们想,能否在at人名填入到输入框的时候,把账户信息塞到输入框里。就像contenteditable一样。因此,我们可以尝试使用一些不可见的字符,用这些字符来表达某个账户信息的标识。 但这种方案,想想就复杂,例如我们删除一个人名时是否能同步把不可见字符也删掉,是否会有光标问题都不好说。 采用了一种虚拟层的思路。当用户输入任何字符时,我们都对用户输入进行拦截,拦截到输入后,首先按照需求更新我们内部 虚拟层的数据结构,在虚拟层中我们按一定的结构保存好用户账户信息数据,然后将其渲染成文本再填到输入框中。

最终,我选择了采用第三种方案实现,方案如图:
image.png

调用方法

虚拟层内部具体的计算逻辑封装到了 RichMessage 类中。小程序组件中,首先给input输入框绑定 input 事件:

<input placeholder="请输入" bindinput="eventInput" />

组件脚本中:

{
    data: {
        inputContent: '',
    },
    attached() {
       this.myCommentRichMessage = new RichMessage();
    },
    methods: {
        eventInput(e) {
            const res =  this.myCommentRichMessage.doInput(event.detail);
            // 输入完之后,重新渲染input内容
            res.then(str => {
                if (typeof str === 'string') {
                    this.setData({
                        inputContent: str
                    });
                }
            });
        }
    }
}

当要把数据提交给后台时,可以调用 toProto 方法,将消息转成具体的数据结构:

const pbdata = myCommentRichMessage.transformToProto()

实现

RichMessage类

负责根据输入的光标和value,计算出是在什么位置新增或删除了字符。并负责维护虚拟层的消息盒子---msgbox。

当用户新增字符,则修改或新增消息盒子中具体位置的消息数据结构。 当用户删除字符,则删除消息盒子中对应位置的字符数据
const MessageBox = require('./MessageBox');

class RichMessage {
    constructor(options) {
        options = options || {};
        this._msgBox = new MessageBox();
    }

    doInput(inputInfo) {
        const { keyCode } = inputInfo;
        // 做下判断,防止鼠标或手机键盘移开时触发的input事件(keyCode是undefined)
        if (isNaN(keyCode)) return Promise.resolve(inputInfo);
        if (keyCode == 8) {
            return this.removeOneCharactor(inputInfo);
        }
        else {
            return this.typeOneCharactor(inputInfo);
        }
    }
}

module.exports = RichMessage;

消息盒子实现

消息盒子负责具体实现两种类型消息的管理:纯文本消息和at消息。其必须实现以下3个api:

addCharactor方法。在pos位置新增一个字符,并重构当前虚拟层数据结构 deleteCharactor方法。在pos位置删除一个字符,并重构当前虚拟层数据结构 print方法。将整个虚拟层各个消息全部渲染,得到一份完整的纯文本

内部实现会有些复杂,更多代码请查看github。例如:

添加字符时会涉及到当 确定pos位置是新增还是修改现有消息pos位置插入at消息,是否要将某个文本消息切分成两半 等情况的处理。 删除字符时,若是删除的at消息则要将整个at消息体删除 每次消息改动后,要像整理'内存'碎片一样,对消息进行合理的合并处理(例如两个相邻的文本消息要合并)
const TextMessage = require('./TextMessage');
const AtMessage = require('./AtMessage');

class MessageBox {
    constructor() {
        this._msgs = [];
    }

    // 向pos位置增加普通文本字符
    addCharactor(pos, char) {
        // 准备要add的消息
        const getNewMsg = this._getNewMsg(char);
        return getNewMsg.then(newMsg => {
            // 找到要放置的位置
            let countPos = 0;
            let findedMsg = null;
            let findedMsgIndex = -1;
            for (let i = 0, len = this._msgs.length; i < len; i++) {
                const msg = this._msgs[i];
                const msgRenderLen = msg.render().length;
                if ((pos >= countPos) && (pos <= (countPos + msgRenderLen - 1))) {
                    // 要操作的位置正好处于该消息结构中
                    findedMsg = msg;
                    findedMsgIndex = i;
                    break;
                }
                countPos += msgRenderLen;
            }

            if (findedMsg) {
                // 找到了消息msg,则把新msg塞到该msg结构里
                this._mergeMsg(findedMsgIndex, newMsg, pos - countPos);
            }
            else {
                // 没找到消息块,那就是放到box末尾新增
                this._msgs.push(newMsg);
            }
            // 消息碎片整理--即把同类型消息合并(例如两个挨着的textmessage则用一个表示即可)
            this._defragmentation();
            return this.print();
        });
    }

    // 删除start到end间的字符(包含end自身)
    deleteCharactor(start, end) {
        const findedMsgIndex = [];
        const findedMsgPos = [];

        let countPosStart = 0;
        for (let i = 0, len = this._msgs.length; i < len; i++) {
            const msg = this._msgs[i];
            const msgRenderLen = msg.render().length;
            const countPosEnd = (countPosStart + msgRenderLen) - 1;
            if (end >= countPosStart && start <= countPosEnd) {
                findedMsgIndex.push(i);
                // 找出此msg里的交集坐标
                const msgPosStart = Math.max(countPosStart, start);
                const msgPosEnd = Math.min(countPosEnd, end);
                findedMsgPos.push({
                    startPos: msgPosStart - countPosStart,
                    endPos: msgPosEnd - countPosStart
                });
            }
            countPosStart += msgRenderLen;
        }
        // 对找到的msg依次进行删除的操作 (若是at信息,则整个都删掉;若是普通字符,则只删对应坐标的字符;若删除后整个msg变空了,则在碎片整理时移除)
        if (findedMsgIndex && findedMsgIndex.length > 0) {
            findedMsgIndex.forEach((findedIndex, index) => {
                const msg = this._msgs[findedIndex];
                if (msg.type === 'text') {
                    const deletePos = findedMsgPos[index];
                    msg.removeChars(deletePos.startPos, deletePos.endPos);
                }
                if (msg.type === 'at') {
                    this._msgs.splice(findedIndex, 1);
                }
            });
        }
        this._defragmentation();
    }

    // 输出当前所有 msg 结构转为可视字符串后的完整字符串
    print() {
        let str = '';
        str = this._msgs.reduce((last, cur) => {
            return last += cur.render();
        }, '');
        return str;
    }

}


module.exports = MessageBox;

完整代码

完整代码看下github吧:

 https://github.com/cuiyongjia...