Vue: 评论区实现发表情和@某人消息推送

7,623 阅读6分钟

简介

在接到这个需求的时候,我就纳闷,为啥要搞这么多花样,评论就评论吧,它还要你实现艾特某人的时候,还要调用后台的IM接口,给相关人员发送通知推送;说到这里,有点经验的JY应该就想到了,数据参数如何组装和传递这是个关键点,后面再细说。

评论区主要实现的功能点有:表情包选择,艾特符识别并弹出人员选择,还有就是图片选择(篇幅有限,这个放在第二篇文章述说),还有就是支持表情包,艾特人,文本组合显示的文本区域(这个是难点)

组合显示的文本区域,除了能正常显示三种元素节点,还需要将其关联的数据包裹起来,方便在点击发送的时候,将这些数据提取出来进行组装,然后发送给后台;

刚开始,毫无头绪,不知从何下手的时候,想到的就是去寻找一劳永逸的插件,然后发现,比较适合做这方面的插件,无非就是富文本编辑器吧,比如最常用的富文本编辑器Vue-Quill-Editor ,但是研究了一番这开源插件的文档发现,开发的API并不能满足我实际的需求,比如艾特人的情况下数据存储,还有改写后光标的显示和跟踪问题,,😮‍💨唉,一番折腾之后又陷入了苦思当中;但问题总归是要解决的,于是我开始萌生了不再依赖插件的想法;经过我一番的努力思索,还真给我整出来了,废话不多说,先上图: QQ20230216-175105-HD.gif 接下来我会从以下几个方面分享我的开发思路喝实现方案

  1. 原型描述
  2. 思路分析
    • 表情模块实现
    • 艾特符模块实现
    • 数据交互模块实现
  3. 代码实现

原型需求

具体的需求是这样子的,请看下方描述:

A.鼠标点击输入框即可开始输入内容

B.当在输入的过程发送了@消息时,被@的人会通过IM收到被@的消息。

  1. 输入@后弹出人员选择框,这里的人员包括“所有人”、“创建人”、“负责人”、“子任务负责人”、“参与人”。排序顺序按照此顺序进行展示。
  2. 排列在第一个的为“所有人”这里的所有人不包括自己。
  3. 下面的其他人员中同样不会显示出自己。
  4. 勾选人后,被勾选的人会显示到输入框中。
  5. 注意:如果@了所有人,又@了单个人,此时发送出去的消息针对个人只会发送一次。

C.通过@发送出去的消息除了会在当前页面进行展示外,还会通过IM将消息发送给对方:

  1. @了几个人就会把消息单独发给那几个人。如果有相同的需要去重,只会发送一次。
  2. 如果@了所有人,那么就会单独发送给所有人(不包含自己)。
  3. 被@的人会在IM中收到消息。这条消息来自发起人与被@的人之间的私聊。如果聊天的内容中包含了附件,则附件不会发送到IM,只会发送本文内容和表情。 做出了的效果如图:

QQ20230216-175213-HD.gif

思路分析

表情模块实现

选择表情包弹层有两种方案,一种是直接图片链接展示,图片命名用中文,另一种是提交给后台的时候是要转译成【微笑】的形式进行保存,而不是图片链接

这里涉及到表情选择后,回显到文本框中。我们所知道的textarea标签只能传进文本内容,所要将div改写成可编辑的文本框,增加contenteditable="true" 属性,通过获取div的Dom,对其进行光标定位,让表情包进行插入,再操作光标移动;可能看到这里的JY有点懵,在代码实现那里会细说。

image.png

image.png

艾特符模块实现

当监听到用户输入@的时候我们弹出人员选择器,这时候我们需要记住现在光标所在的位置,当用户选择人员完毕之后,我们创建一个Dom在插入到我们刚刚记录光标的位置,并且把我们输入的@删除,将光标放在这个节点的最后;似乎过程描述得还蛮简单的,但不过实现起来还是有点难度;

一、使用到的JavaScript对象:

1.Range Range对象表示的包含节点或者文本节点一段片段
2.Selection Selection对象表示用户的光标开始位置到结束位置的选区
(以上是我的个人理解,具体的需要到MDN上查阅)

二、实现的原理

  1. 首先通过const selection = Window.getSelection(),
  2. const range = selection.getrangeAt(0)获取光标的位置
  3. 然后监听键盘事件,阻止输入@的默认行为,并且创建一个SPAN标签,内容为@,然后到光标处;
  4. 创建两个新的span标签,把@+选中的内容让放到其中一个新建的span标签中,另外一个span标签插入空格
  5. 创建一个fragment片段,把第四步中两个span标签一次插入fragment中
  6. 最后使用Range对象中的insertNode()方法插入富文本中
  7. 第6步完成之后,找到第4步创建并带有@的SPAN节点,然后移除
  8. 删除时,首先找到包含@+内容的节点,然后把整个节点一起删除

数据交互模块实现

比如我要评论这样一条信息

image.png 那么我们需要怎样存储后端接口需要到的参数格式呢,比如IM推送,需要知道你艾特的是谁,还有表情包,到底要的是链接,还是标识符,这些需要分析如何存储和传递的, 而我这个项目的接口需要的数据格式如下:

image.png

  • 其中imText是用于在IM聊天窗口中,发送消息的数据格式,
  • text部分是直接用于评论任务记录的显示,
  • users是用于存储艾特相关人员的用户id存储

显而易见,我们需要做的就是将users—id存储到艾特符元素里面,imText需要我们从内容框中得到的html,再做个正则匹配将其内容转换和替换成我们需要的数据格式;至于如何实现,待会在代码实现栏上详说

代码实现

表情包评论代码解析

选中表情

appendEmoji(imgSrc) {
  // 拿到dom获取光标
  const editor = this.$refs.jsEditorElement;
  if (editor) {
    this.isFocus()
    console.log(editor.focus);
    this.selectEmoji(imgSrc);
    console.log('onChangeJsEditor')
    this.onChangeJsEditor('emoji')
  }
  this.visibleEmoj = false;
}

将文本框的光标位置移动到添加表情后的位置

 selectEmoji(url) {
  const editor = this.$refs.jsEditorElement;
  if (editor) {
    editor.focus();
    // this.editorRange.selection.collapseToEnd();
    // 删掉草稿start
    const editorRange = this.editorRange.range;
    console.log(
      "editorRange",
      editorRange,
      editorRange.startOffset,
      editorRange.endOffset
    );
    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);
    editorRange.deleteContents(); // 删除草稿end
    const dom = this.createInsterImgData(url);
    console.log(dom);
    console.log(this.editorRange.selection);
    console.log(this.editorRange.range);
    this.insertHtmlImgAtCaret(
      dom,
      this.editorRange.selection,
      this.editorRange.range
    );
    // }
  }
},

将表情包以加装后img标签的形式累加到文本显示

createInsterImgData(url) {
  const btn = document.createElement("img");
  btn.setAttribute("src", url);
  btn.setAttribute("class", "emo");
  btn.setAttribute("style", "width: 26px;height:26px;");
  return btn;
},

@艾特某人代码解析

一、选择@按钮事件

可以阅读源码中的selectPerson()方法,这里主要说下,保存@某人信息的问题

给每一个选择的人员,构造一个Dom,设置为a标签元素,然后将用户信息存放在dataset里面,并设置一个classs属性(用于提交的时候通过正则匹配出来提取用户信息), 如下:

this.insertCaret(
  `<a data-id="${some.member_id}" data-mid="${some.im_user_id}" class="userSetClass" 
  data-name="${some.member_name}" contenteditable="false">@${some.member_name}</a>&nbsp;`
);
createInsterData(personArr) {
  const temp = [];
  for (const person of personArr) {
    const btn = document.createElement("a");
    btn.dataset.id = person.member_id;
    btn.dataset.mid = person.im_user_id;
    btn.dataset.name = person.member_name || person.name;
    btn.contentEditable = false;
    btn.setAttribute("href", "javascript:void(0)");
    if(this.allowSelectMembers.length === personArr.length) {
      btn.textContent = ` &${person.member_name} `;
      btn.setAttribute('style', 'display:none')
      btn.setAttribute("class", "userHiddenSetClass");
    } else {
      btn.textContent = ` @${person.member_name} `;
      btn.setAttribute("class", "userSetClass");
    }
    btn.addEventListener(
      "click",
      () => {
        return false;
      },
      false
    );
    btn.tabindex = "-1";
    const bSpaceNode = document.createTextNode("\u200B"); // 不可见字符,为了放光标方便
    temp.push(btn);
    temp.push(bSpaceNode);
  }
  // 将所有添加进去@所有人
  if(this.allowSelectMembers.length === personArr.length) {
    const btn = document.createElement("a");

    btn.type = "link";
    btn.textContent = ` @所有人 `;
    btn.contentEditable = false;
    btn.setAttribute("class", "userSetAllClass");
    btn.setAttribute("href", "javascript:void(0)");
    btn.tabindex = "-1";
    const bSpaceNode = document.createTextNode("\u200B"); // 不可见字符,为了放光标方便
    temp.push(btn);
    temp.push(bSpaceNode);
  }
  return temp;
},

二、输入@符号监听

这里要写个监听函数,当用户按 shift + @ 的时候会被检索到,然后执行获取光标事件,并同时更新人员弹层选择列表getAllNewMembers(),

onInputText(e) {
  this.onChangeJsEditor(e.target.innerHTML)
  console.log(e.target.innerHTML)
  // 这是输入了@,那就直接弹选人浮层
  this.doToggleDialog();
  console.log(this.editorRange);
  if (e.code === "Digit2" && e.shiftKey) {
    this.mockInput = false;
    console.log("输入@");
    // 获取新的参与人alt列表
    this.getAllNewMembers()
  }
},

三、发布评论前的数据转换

刚才在前面两个模块做的数据组装就是为了最后一步,发表评论的数据提取和传递问题

首先提取用户信息,将其存放在一个数组中

let collect = editor.getElementsByClassName("userSetClass");
console.log(collect, Array.from(collect).length);
for (const child of collect) {
  atidsss.push(child.dataset.id);
  atnames.push(child.dataset.name);
  atmid.push(child.dataset.mid);
}
// @所有人
let AltAll = editor.getElementsByClassName("userSetAllClass")
// if(AltAll.)
if(AltAll && Array.from(AltAll).length > 0) {
  let userHiddenSetClass = editor.getElementsByClassName("userHiddenSetClass")
  for (const child of userHiddenSetClass) {
    atidsss.push(child.dataset.id);
    atnames.push(child.dataset.name);
    atmid.push(child.dataset.mid);
  }
}

其次,将带有img标签的html文本,通过正则表达式将其提取出图片名称,用于IM的消息推送格式

params.imText = params.imText.replace(
  /<img [^>]*src=['"]([^'"]+)[^>]*>/gi,
  function (match, capture) {
    console.log(capture);
    let name = that.getLastFileName(capture)
    if(name) {
      return `[${name}]`
    }
    return ''
  }
);

最后就是整理数据,将后端定义好的数据格式传递给他们

let params = {
  task_id: this.activeTaskItemDetailID,
  text: this.$refs.jsEditorElement.innerHTML,
  imText: this.$refs.jsEditorElement.innerHTML,
  images: this.imgList,
  users: atidsss ? atidsss.filter((v) => v) : [],
  attachment: this.fileList,
};

完结 如果您还有力气,麻烦点个小指头,支持下感谢!

附赠源码:

往期推荐

一、Vue3.2: 仿飞书App组织架构选人组件封装

二、前端UI: 切图仔的新技能—PS动图制作

三、Vue: 如何实现树状收展及勾选联动功能-超实用干货