前端web实现(@、At、艾特)选人或引用数据

4,732 阅读3分钟

前言

在我们日常的网络社交中,@XXX 功能可以说是一个比较常见的功能了。 本文将结合实践,介绍一种可以快速实现 @ 选人或引用数据的方式。

功能需求

简单的说一下需求:

1、在输入框中输入 @ ,弹出浮窗,然后可以选择浮窗中相关的数据; 2、在输入框中输入 # ,弹出浮窗,然后可以选择浮窗中相关的数据; 3、@# 引用的数据要包含名称和id等,最终要传给后端; 4、删除 @# 引用的数据时,需要整体删除; 5、@# 引用的数据需要被标注成不同的颜色。

大致就是这样。

技术方案

在网上参考了不少大佬的文章,也大致了解了一些社交平台的实现方式,有兴趣的朋友可以看看文末的参考。

最终因为功能的契合度和时间原因,我选择了开源库: tributejs 。这个开源库有原生,Vue 等例子,就是没有 React 的例子,但是问题不大,使用方式都是大同小异。

具体实现

本文的 @XXX 功能是 tributejs + React实现的,所以 React 技术栈的同学可以直接参考后面的例子,其他技术栈的同学可以参考 tributejs 官方的实现。

@功能实现

首先当然是要下载 tributejs:

yarn add tributejs
或者
npm install tributejs

然后就是引入 tributejs,对想要的功能进行配置,具体各项配置的意义,可以直接到 tributejs 的 GitHub 上查看。最后可以给编辑器加一些自定义的样式:

index.tsx

import React, { useEffect, useState, useRef  } from 'react';
import Tribute from "tributejs";
import './index.less';

const AtDemo = () => {
  const [atList, setAtList] = useState([
    {
      key: "1",
      value: "小明",
      position: "前端开发工程师"
    },
    {
      key: "2",
      value: "小李",
      position: "后端开发工程师"
    }
  ]);
  const [poundList, setpoundList] = useState([
    { name: "JavaScript", explain: "前端开发语言" },
    { name: "Java", explain: "后端开发语言之一" }
  ]);

  useEffect(() => {
    renderEditor(atList, poundList);
  }, [])

  const renderEditor = (_atList: any[], _poundList: any[]) => {
    let tributeMultipleTriggers = new Tribute({
      allowSpaces: true,
      noMatchTemplate: function () { return null; },
      collection: [
        {
          selectTemplate: function(item) {
            if (this.range.isContentEditable(this.current.element)) {
              return (
                `<span contenteditable="false">
                  <span
                    class="at-item"
                    title="${item.original.value}"
                  >
                    @${item.original.value}
                  </span>
                </span>`
              );
            }

            return "@" + item.original?.value;
          },
          values: _atList,
          menuItemTemplate: function (item) {
            return item.original.value;
          },
        },
        {
          trigger: "#",
          selectTemplate: function(item) {
            if (this.range.isContentEditable(this.current.element)) {
              return (
                `<span contenteditable="false">
                  <span
                    class="pound-item"
                  >
                    #${item.original.name}
                  </span>
                </span>`
              );
            }

            return "#" + item.original.name;
          },
          values: _poundList,
          lookup: "name",
          fillAttr: "name"
        }
      ]
    });

    tributeMultipleTriggers.attach(document.getElementById("editorMultiple") as HTMLElement);
  }
  
  return (
      <div className="at-demo">
          <div
            id="editorMultiple"
            className="tribute-demo-input"
            placeholder="请输入"
          ></div>
      </div>
  )
}

export default AtDemo;

index.less

.at-demo {
    background-color: #fff;
    padding: 24px;
    .at-item, .pound-item {
        color: #2ba6cb;
    }
}
.tribute-container {
    position: absolute;
    top: 0;
    left: 0;
    height: auto;
    overflow: auto;
    display: block;
    z-index: 999999;
  }
  .tribute-container ul {
    margin: 0;
    margin-top: 2px;
    padding: 0;
    list-style: none;
    background: #fff;
    border: 1px solid #3c98fa;
    border-radius: 4px;
  }
  .tribute-container li {
    padding: 5px 5px;
    cursor: pointer;
    border-radius: 4px;
  }
  .tribute-container li.highlight {
    background: #eee;
  }
  .tribute-container li span {
    font-weight: bold;
  }
  .tribute-container li.no-match {
    cursor: default;
  }
  .tribute-container .menu-highlighted {
    font-weight: bold;
}
.tribute-demo-input {
  outline: none;
  border: 1px solid #d9d9d9;
  padding: 4px 11px;
  border-radius: 2px;
  font-size: 15px;
  min-height: 100px;
  cursor: text;
}
.tribute-demo-input:hover {
  border-color: #3c98fa;
  transition: all 0.3s;
}
.tribute-demo-input:focus {
  border-color: #3c98fa;
}
[contenteditable="true"]:empty:before {
  content: attr(placeholder);
  display: block;
  color: #ccc;
}
#test-autocomplete-container {
  position: relative;
}
#test-autocomplete-textarea-container {
  position: relative;
}
.float-right {
  float: right;
}


我们可以看看效果,还是很不错的:

演示.gif

被引用的数据也是被整体删除的:

演示2.gif

获取编辑器中的数据

我们在编辑器中输入了我们想要的数据,那最终都是要获取其中的数据并且传递给后端的:


...

import { Button } from 'antd';
// 转义HTML
const htmlEscape = (html: string) => {
  return html.replace(/[<>"&]/g,function(match,pos,originalText){
    switch(match){
      case "<":
          return "&lt;";
      case ">":
          return "&gt;"
      case "&":
          return "&amp;";
      case "\"":
          return "&quot;";
      default:
        return match;
    }
  });
}

const AtDemo = () => {

    ...
    
    const getDataOfEditorMultiple = () => {
        const childrenData = document.getElementById('editorMultiple')?.innerHTML;
        console.log('childrenData', childrenData)
        const toServiceData = htmlEscape(childrenData);
        console.log('toServiceData', toServiceData)
    }
  
    return (
      <div className="at-demo">
          <div
            id="editorMultiple"
            className="tribute-demo-input"
            placeholder="请输入"
          ></div>
          <Button onClick={getDataOfEditorMultiple}>获取输入框中所有元素</Button>
      </div>
    )
}

我们可以直接通过 getDataOfEditorMultiple 方法直接获取编辑器中的数据,并且转义之后发送给后端。

实时获取编辑器中被引用的数据

我们有时候可能需要实时的监听编辑器中所数据的数据,或者是被引用的数据。这时我们可以调用 oninput 这个方法。当然也可以在其他情况调用 onbluronfocus 这两个方法,顾名思义就是失去焦点时和获取焦点时。

完整的代码如下:

import React, { useEffect, useState, useRef  } from 'react';
import './index.less';
import Tribute from "tributejs";
import { Button } from 'antd';

const htmlEscape = (html: string) => {
  return html.replace(/[<>"&]/g,function(match,pos,originalText){
    switch(match){
      case "<":
          return "&lt;";
      case ">":
          return "&gt;"
      case "&":
          return "&amp;";
      case "\"":
          return "&quot;";
      default:
        return match;
    }
  });
}

const AtDemo = () => {
  const [atList, setAtList] = useState([
    {
      key: "1",
      value: "小明",
      position: "前端开发工程师"
    },
    {
      key: "2",
      value: "小李",
      position: "后端开发工程师"
    }
  ]);
  const [poundList, setpoundList] = useState([
    { name: "JavaScript", explain: "前端开发语言" },
    { name: "Java", explain: "后端开发语言之一" }
  ]);

  useEffect(() => {
    renderEditor(atList, poundList);
  }, [])

  const renderEditor = (_atList: any[], _poundList: any[]) => {
    let tributeMultipleTriggers = new Tribute({
      allowSpaces: true,
      noMatchTemplate: function () { return null; },
      collection: [
        {
          selectTemplate: function(item) {
            if (this.range.isContentEditable(this.current.element)) {
              return (
                `<span contenteditable="false">
                  <span
                    class="at-item"
                    title="${item.original.value}"
                    data-atkey="${item.original.key}"
                    data-atvalue="${item.original.value}"
                  >
                    @${item.original.value}
                  </span>
                </span>`
              );
            }

            return "@" + item.original?.value;
          },
          values: _atList,
          menuItemTemplate: function (item) {
            return item.original.value;
          },
        },
        {
          trigger: "#",
          selectTemplate: function(item) {
            if (this.range.isContentEditable(this.current.element)) {
              return (
                `<span contenteditable="false">
                  <span
                    class="pound-item"
                    data-poundname="${item.original.name}"
                  >
                    #${item.original.name}
                  </span>
                </span>`
              );
            }

            return "#" + item.original.name;
          },
          values: _poundList,
          lookup: "name",
          fillAttr: "name"
        }
      ]
    });

    tributeMultipleTriggers.attach(document.getElementById("editorMultiple") as HTMLElement);
  }
  
  const getDataOfEditorMultiple = () => {
    const childrenData = document.getElementById('editorMultiple')?.innerHTML || '';
    console.log('childrenData', childrenData)
    const toServiceData = htmlEscape(childrenData);
    console.log('toServiceData', toServiceData)
  }

  const onInput = () => {
    const atItemList = document.getElementsByClassName('at-item');
    Array.prototype.forEach.call(atItemList, function(el) {
      console.log(el.dataset.atkey);
      console.log(el.dataset.atvalue);
    });
  }
  return (
      <div className="at-demo">
          <div
            id="editorMultiple"
            className="tribute-demo-input"
            placeholder="请输入"
            onInput={onInput}
          ></div>
          <Button onClick={getDataOfEditorMultiple}>获取输入框中所有元素</Button>
      </div>
  )
}

export default AtDemo;

几个关键点的实现

这里提一下几个关键功能点的实现原理。

  • 编辑器的输入框利用的是普通的 div 标签,然后采用 contenteditable="true" 这个属性来实现的;
  • 引用数据的浮窗定位可以利用 Selection对象来获取;
  • 被 @ 或 # 引用的数据,想要被一次性删除,可以在被 @ 或 #的数据外包含一个 <span contenteditable="false"></span>,表示不可编辑的标签;
  • 把被引用的数据定义为特定的颜色,这个因为我们在输入框中插入引用数据时,被引用的数据是被HTML标签包裹着的,所以我们只需要对相关的HTML进行样式设置就好了;
  • 想要获取被引用数据中的多个属性的值,可以和上面的例子一样,利用HTML5的自定义属性 data-xxx 来保存我们想要的属性值,然后通过遍历标签 el.dataset.xxx 获取我们想要的属性的值。

最后

本文介绍了一种可以在前端快速实现 @xxx 选人或引用数据的功能,在部分情景下也算是比较好的解决方案了。有兴趣的同学可以看看文末参考文章中其他大佬们的实现方式。

参考

github.com/zurb/tribut… segmentfault.com/a/119000003… segmentfault.com/a/119000000… juejin.cn/post/698225… mp.weixin.qq.com/s/YP6H6CHkU…