聊天框(番外篇)—如何实现@功能的整体删除

1,649 阅读5分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第1天,点击查看活动详情

上一篇文章中,我们已经初步实现了聊天输入框,但其@功能是不完善的,例如无法整体删除、无法获取除用户名以外的数据(假设用户名不是唯一的)。有问题就要想办法解决,在网上百度了一圈后,倒是有一些收获。本文就着重解决@的整体删除以及获取额外数据。

准备工作

聊天框的实现是基于div+contenteditable的。一旦元素开启了可编辑属性,就可以像输入框一样输入内容。不同的是,我们可以传入HTML格式的代码,这也是可以渲染出来的。

<div contenteditable><p style="color: red;">hello</p></div>
<div contenteditable>hello</div>

回到我们之前实现的@功能,是直接插入的特殊字符串,删除时也只能一个一个字符的删除,当然也可以通过监听backspacedelete按键,结合正则表达式,手动移动光标来删除,这种方式过于复杂,笔者也没有搞明白要怎么操作;根据上述的渲染结果,我们是不是可以考虑将@xxx也当作一个HTML标签插入到输入框中,然后保证删除时能整体删除就可以了。

初步解决方案

在插入HTML标签前,我们先来了解替换元素非替换元素

替换元素是指浏览器会元素的标签和属性,来决定元素的具体显示内容,如果未指定属性则显示的将是一个空标签,传入不同的属性值其在页面上渲染出来的结果也不一样。常见的替换元素包括:imginputtextareaselectobject

非替换元素是指其内容可以直接展现给浏览器,HTML中大多数元素是不可替换元素。

了解了这个有什么用呢,我们可以试试在可编辑元素中插入一个替换元素标签:

<div contenteditable>
    <input value="@xxx" readonly />
</div>
<div contenteditable>
    <img src="xxx" alt="@xxx" />
</div>

可以发现,在输入框内是可以整体删除@xxx,基本功能是可以实现的,但是有几个问题需要解决:

  • input有默认的宽度,并且需要指定为只读,由于我们的@xxx宽度是不定的,需要使得input的宽度自适应,实现起来比较负责,笔者未实现
  • img标签需要指定有效的src属性,如果指定的src无效,即使有alt属性值,也会有一个裂开的图片。为了解决这个裂开的图片,笔者尝试了多种方法都没有解决,真是要裂开了。解决不了裂开的图片,笔者就尝试将@xxx通过html2canvascanvas2img的方式将其转换为base64的图片,效果也还行,就是转换的过程有点慢,也被pass掉了。

难道除了这种方式就没有别的了嘛,答案是有的。

最终解决方案

不是说使用inputimg标签不行,只是用起来有点麻烦,于是乎笔者就将目光转向了非替换元素。先来一个a标签试试水:

<div contenteditable>
    <a contenteditable="false">@xxx</a>
</div>

可编辑元素的子元素默认也是可编辑的,因此需要设置a标签为不可编辑,不然就无法实现整体删除。运行上面的例子,可以发现基本上符合我们的预期效果,想要完美实现,还得考虑几点:

  • 除了@xxx本身外,还应该携带唯一的标识,这样才能区分艾特了哪些人
  • 如果是输入@,然后选择了具体的成员,那么之前输入的@应该被删除掉
  • 如何保证光标是在@xxx后面

有了具体的问题,我们就来逐一解决:

  1. 额外参数

    a标签可以在value中携带唯一标识,然后我们在发送文本时,从文本内容中取出被艾特的人即可。

    <div contenteditable>
        <a name="at" :value="username" contenteditable="false">@xxx</a>
    </div>
    
  2. 删除之前输入的@

    因为我们在a标签中已经包含了@,因此之前输入的@就需要删除。也许有人会想,我要通过调用backspce或者delete来实现,可惜这种方式并不可行。正确的思路应该是通过字符串的替换,来模拟实现删除功能。我们这里采用正则表达式的方式来处理,因为我们只需要将@xxx左边最近的一个@删除即可:

    if (/@<a name="at"/.test(this.$refs.editor.innerHTML)) {
        this.$refs.editor.innerHTML = this.$refs.editor.innerHTML.replace(/@<a name="at"/, '<a name="at"');
    }
    

    我们使用name="at"只是替换在输入框中的@,如果有其他的a标签我们将不处理。使用直接替换的方式,会导致光标默认跑到开头,这显然不符合要求,接下来处理光标

  3. 处理光标位置

    我们需要将光标插入到@xxx后面,具体是哪一个@xxx就需要通过getElementById()来查找,然后将光标移动到此元素后面,因此我们在插入a标签时,还应该指定每一个a标签的id,使用一个递增的全局变量即可。

    // DivEditable.vue
    if (/@<a name="at"/.test(this.$refs.editor.innerHTML)) {
        // 使用正则替换,将已经输入的@替换掉
        // 如果直接赋值修改innerHTML,则光标默认会回到开头。因此需要额外处理光标
        this.$refs.editor.innerHTML = this.$refs.editor.innerHTML.replace(/@<a name="at"/, '<a name="at"');
        // id表示哪一个@
        let el = document.getElementById(id);
        range = document.createRange();
        sel = window.getSelection();
        // 将光标重新定位到自定义的a标签后面
        range.setStartAfter(el);
        range.setEndAfter(el);
    ​
        sel.removeAllRanges();
        sel.addRange(range);
    }
    
    // InputBox.vue
    onSelect(item) {
        this.atIndex++;
        // 使用a标签表示@的成员
        let at = `<a name="at" value="${item.userName}" tabindex="-1" id="${this.atIndex}" contenteditable="false" href="javascript:void(0)">@${item.name}</a>&#x200B;`;
        this.$refs.inputBox.insertContent(at, this.atIndex);
        console.log('onSelect', item);
        // this.$refs.inputBox.insertContent(`${item.name} `); // 有空格
        this.isShowAt = false;
    },
    
  1. 获取@的成员

    我们通过正则表达式来获取

    let atIds = [];
    this.$refs.editor.innerHTML.replace(/<a [^>]*value=['"]([^'"]+)[^>]*/gi, function(match, capture) {
        atIds.push(capture);
    })
    

这样我们就基本实现了使用a标签来完成@的整体删除,这里有一个小细节。一般我们都会在@xxx后面有一个空格,我们可以使用&nbsp;,也可以使用零宽字符&#x200B;。笔者发现在不同的浏览器上,使用空格和零宽字符的效果还是有所差异的。

总结

本文介绍了使用a标签来完成@功能的整体删除,当然除了使用a标签,span、button、img等标签都是可以选择的技术方案,实现的原理都差不多。不管是采用哪种方案,都需要注意几点:

  • 可编辑元素的子元素默认也是可编辑的,因此插入标签时需要设置为不可编辑
  • 插入标签时,需要将已经输入的@字符删除
  • 注意光标位置的处理
  • 标签自身的样式需要部分覆盖,具体的看使用情况

若有其他解决方案,欢迎在评论区补充。最后,完整代码可参考 项目地址

原创不易,转载请注明出处