17 - vue仿微信聊天的发送表情包、@成员功能

1,006 阅读3分钟

仿微信聊天的选择微信表情、@成员功能,当时写的时候时间比较赶,代码可能会有点乱,效果没有微信的那么流畅,凑合着看吧

案例的仓库地址:gitee.com/mayxue/vue2…

一、效果

1.1 选择表情包

wxEmojis.png

表情包雪碧图

1.2 @成员

二、实现过程

2.1 页面布局-sendBox.vue

2.1.1 页面布局(sendBox.vue)

<template>
  <div class="chatToolsPane" @click="closeWxEmojisDialogShow">
    <!-- chatTools-工具-start -->
    <div class="chatTools">
      <!-- 表情包选择-组件-弹窗-start -->
      <wxEmojisDialog
        v-if="wxEmojisDialogShow"
        @wxEmojisClick="wxEmojisClick"
      ></wxEmojisDialog>
      <!-- 表情包选择-组件-弹窗-end -->

      <!-- 聊天操作-start -->
      <div class="chatOperation">
        <div class="toolBtn">
          <!-- 表情包按钮-start -->
          <span
            class="expressionBtn"
            @click.stop="wxEmojisDialogShow = true"
          >
            <!-- <svg-icon icon-class="expression" /> -->
            <img src="@/assets/images/emojisIco.png" />
          </span>
          <!-- 表情包按钮-end -->
        </div>
        <div class="chatTextareaDiv">
          <div
            contentEditable="true"
            contenteditable="plaintext-only"
            ref="chatTextarea"
            id="chatTextarea"
            class="chatTextarea"
            @keyup="handkeKeyUp"
            @keydown="handleKeyDown"
            @blur="chatTextareaClick"
          ></div>
          <div class="chatSubmitBtn">
            <el-button
              type="primary"
              size="small"
              @click="chatSubmit()"
              >发送</el-button
            >
          </div>
        </div>
      </div>
      <!-- 聊天操作-end -->
    </div>
    <!-- chatTools-工具-end -->

    <!-- @成员弹窗-start -->
    <atDialog
      v-if="atDialogShow"
      :visible="atDialogShow"
      :position="position"
      :queryString="queryString"
      :mockList="groupUserData"
      @onPickUser="handlePickUser"
      @onHide="handleHide"
      @onShow="handleShow"
    ></atDialog>
    <!-- @成员弹窗-end -->
  </div>
</template>
<script>
import { mapGetters } from "vuex";
import { wxEmojis } from "@/utils/wxEmojis";
import sendChatMsg from "@/utils/sendChatMsg";
import { atDialog, wxEmojisDialog } from "./components/index";
export default {
  name: "sandBox",
  components: { atDialog, wxEmojisDialog },
  data() {
    return {
      wxEmojisDialogShow: false, //表情包选择弹窗状态
      node: "", // 获取到节点
      groupUser: "", // 选中项的内容
      endIndex: "", // 光标最后停留位置
      queryString: "", // 搜索值
      atDialogShow: false, // 是否显示弹窗
      position: {
        x: 0,
        y: 0,
      }, // 弹窗显示位置
      chatTextarea: "",
      //@成员数据
      groupUserData: [
        { nick_name: "HTML", user_id: "HTML" },
        { nick_name: "CSS", user_id: "CSS" },
        { nick_name: "Java", user_id: "Java" },
        { nick_name: "JavaScript", user_id: "JavaScript" },
      ],
    };
  },
  computed: {
    ...mapGetters(["wxEmojisData"]),
  },
  watch: {
    queryString(val) {
      if (val) {
        //模糊查询
        this.mockList = this.groupUserData.filter(({ nick_name }) =>
          nick_name.includes(val)
        );
      } else {
        this.mockList = this.groupUserData.slice(0);
      }
      this.position = this.getRangeRect(this.mockList.length);
      console.log("watch-mockList", this.mockList);
    },
  },
  methods: {
    //关闭微信表情包弹窗
    closeWxEmojisDialogShow() {
      this.wxEmojisDialogShow = false;
    },

    //微信默认表情包回显
    wxEmojis(html) {
      return wxEmojis(html);
    },

    //点击表情包
    wxEmojisClick(item) {
      let wxEmojis = this.wxEmojis(item);
      console.log("wxEmojis", wxEmojis);
      this.$refs.chatTextarea.focus();
      this.insertHtmlAtCaret(wxEmojis);
    },

    //点击聊天框
    chatTextareaClick() {
      //this.$refs.chatTextarea.focus();
      // 获取选定对象
      var selection = getSelection();
      // 设置最后光标对象
      this.chatBlurIndex = selection.getRangeAt(0);
    },

    //发送消息
    chatSubmit() {
      this.chatTextarea = this.$refs.chatTextarea.innerHTML;
      let atNode = document.querySelectorAll(".chatTextarea span"); //获取所有的@成员节点
      let userArray = [];
      console.log("atNode", atNode);
      for (let i = 0; i < atNode.length; i++) {
        if (atNode[i].getAttribute("data-user")) {
          userArray.push(
            JSON.parse(atNode[i].getAttribute("data-user")).user_id
          );
        }
      }
      let wxEmojisNode = document.querySelectorAll(".chatTextarea i"); //获取所有的表情包节点
      //遍历表情包,将表情包节点替换成转义表情包字符:[微笑]
      for (let i = 0; i < wxEmojisNode.length; i++) {
        for (let j = 0; j < this.wxEmojisData.length; j++) {
          if (
            wxEmojisNode[i].getAttribute("data-value") ==
            this.wxEmojisData[j]
          ) {
            //将表情包节点替换成转义表情包字符:[微笑]
            this.chatTextarea = this.chatTextarea.replace(
              wxEmojisNode[i].outerHTML.toString(),
              this.wxEmojisData[j]
            );
          }
        }
      }
      console.log("userArray", userArray);
      console.log(
        "发送消息this.chatTextarea",
        this.trimHtml(this.chatTextarea)
      );
    },

    //从html代码中获取纯文本
    trimHtml(str) {
      return sendChatMsg.trimHtml(str);
    },

    // 获取光标位置
    getCursorIndex() {
      return sendChatMsg.getCursorIndex();
    },

    // 获取节点
    getRangeNode() {
      return sendChatMsg.getRangeNode();
    },

    // @成员弹窗出现的位置
    getRangeRect() {
      return sendChatMsg.getRangeRect();
    },

    // 是否展示 @
    showAt() {
      return sendChatMsg.showAt();
    },

    // 获取 @ 用户
    getAtUser() {
      return sendChatMsg.getAtUser();
    },

    //选择表情包后在聊天框内插入html
    insertHtmlAtCaret(html) {
      return sendChatMsg.insertHtmlAtCaret(html, this.chatBlurIndex);
    },

    // 插入@标签
    replaceAtUser(replaceHtml, that) {
      return sendChatMsg.replaceAtUser(replaceHtml, that);
    },

    // 键盘抬起事件
    handkeKeyUp() {
      if (this.showAt()) {
        const node = this.getRangeNode();
        const endIndex = this.getCursorIndex();
        this.node = node;
        this.endIndex = endIndex;
        this.position = this.getRangeRect();
        this.queryString = this.getAtUser() || "";
        this.atDialogShow = true;
      } else {
        this.atDialogShow = false;
      }
    },

    // 键盘按下事件
    handleKeyDown(e) {
      if (this.atDialogShow) {
        if (
          e.code === "ArrowUp" ||
          e.code === "ArrowDown" ||
          e.code === "Enter"
        ) {
          e.preventDefault();
        }
      }
    },

    // 插入标签后隐藏选择框
    handlePickUser(groupUser) {
      let that = this;
      this.replaceAtUser(groupUser, that);
      this.groupUser = groupUser;
      this.atDialogShow = false;
    },

    // 隐藏选择框
    handleHide() {
      this.atDialogShow = false;
    },

    // 显示选择框
    handleShow() {
      this.atDialogShow = true;
    },
  },
};
</script>

<style scoped lang="scss">
//chatTools-工具
div:focus {
  outline: none;
}
.chatToolsPane {
  margin: 400px 200px 0 200px;
  border: 1px solid #e6e6e6;
  width: 690px;
  height: 160px;
}
.chatTools {
  height: 148px;
  position: relative;
  .joinChat {
    display: flex;
    align-items: center;
    justify-content: center;
    flex-wrap: wrap;
    margin-top: 40px;
    .joinChatBtn {
      width: 100%;
      text-align: center;
      margin-top: 15px;
    }
  }
  .svg-icon {
    width: 20px !important;
    height: 20px !important;
  }

  //聊天操作-start
  .chatOperation {
    padding: 10px 20px;
    .toolBtn {
      span {
        display: inline-block;
        margin-right: 15px;
        cursor: pointer;
      }
    }
  }
  //聊天框-发送消息-start
  .chatTextareaDiv {
    position: relative;
    height: 110px;
    ::v-deep .el-textarea__inner,
    .chatTextarea {
      height: 70px;
      padding: 5px 0;
      border: none;
      color: #333;
      resize: none;
      background: none;
      line-height: 22px;
      position: relative;
      overflow-y: auto;
      word-wrap: break-word;
      width: 651px;
    }
    .chatTextarea::-webkit-scrollbar {
      width: 6px;
    }
    .chatTextarea::-webkit-scrollbar-track {
      background-color: #fff;
      border-radius: 10px;
    }
    .chatTextarea::-webkit-scrollbar-thumb {
      background-color: #c1c1c1;
      border-radius: 20px;
    }
    ::v-deep .el-textarea__inner::-webkit-scrollbar {
      width: 6px;
    }
    ::v-deep .el-textarea__inner::-webkit-scrollbar-track {
      background-color: #fff;
      border-radius: 10px;
    }
    ::v-deep .el-textarea__inner::-webkit-scrollbar-thumb {
      background-color: #c1c1c1;
      border-radius: 20px;
    }
    .chatSubmitBtn {
      position: absolute;
      bottom: 5px;
      right: 0;
      ::v-deep .el-button--small {
        padding: 7px 30px;
      }
    }
  }
  //聊天框-发送消息-end
}
</style>

<style lang="scss">
.chatTextarea {
  //微信默认表情
  .wxEmojis {
    display: inline-block;
    width: 24px;
    height: 24px;
    background: url("~@/assets/images/wxEmojis.png") top left
      no-repeat;
    // background-position: -3px -3px;
    vertical-align: bottom;
    margin: 0 1px;
  }
  i {
    font-style: normal;
  }
}
</style>

2.1.2  @成员弹窗组件布局(atDialog.vue)

<template>
  <!-- @成员弹窗 -->
  <div
    class="atDialog"
    :style="{
      position: 'fixed',
      top: position.y + 'px',
      left: position.x + 'px',
    }"
  >
    <div class="groupUserList">
      <div
        v-for="(item, i) in mockList"
        :key="item.user_name"
        class="item"
        :class="{ active: i === index }"
        ref="usersRef"
        @click="clickAt($event, item)"
      >
        <div class="avatar"><img :src="item.head_img" /></div>
        <div class="name">{{ item.nick_name }}</div>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: "AtDialog",
  props: {
    visible: Boolean,
    position: Object,
    queryString: String,
    mockList: {
      type: Array,
      default: [],
    },
  },
  data() {
    return {
      users: [],
      index: -1,
    };
  },
  watch: {},
  mounted() {
    document.addEventListener("keyup", this.keyDownHandler);
  },
  destroyed() {
    document.removeEventListener("keyup", this.keyDownHandler);
  },
  methods: {
    keyDownHandler(e) {
      if (e.code === "Escape") {
        this.$emit("onHide");
        return;
      }
      // 键盘按下 => ↓
      if (e.code === "ArrowDown") {
        if (this.index >= this.mockList.length - 1) {
          this.index = 0;
        } else {
          this.index = this.index + 1;
        }
      }
      // 键盘按下 => ↑
      if (e.code === "ArrowUp") {
        if (this.index <= 0) {
          this.index = this.mockList.length - 1;
        } else {
          this.index = this.index - 1;
        }
      }
      // 键盘按下 => 回车
      if (e.code === "Enter") {
        if (this.mockList.length) {
          const user = {
            nick_name: this.mockList[this.index].nick_name,
            user_name: this.mockList[this.index].user_name,
          };
          this.$emit("onPickUser", user);
          this.index = -1;
        }
      }
    },
    clickAt(e, item) {
      const groupUser = {
        nick_name: item.nick_name,
        user_name: item.user_name,
      };
      this.$emit("onPickUser", groupUser);
      this.index = -1;
    },
  },
  created() {},
};
</script>

<style scoped lang="scss">
.atDialog {
  .groupUserList {
    width: 200px;
    background: #fff;
    // box-shadow: 0 2px 12px 0 rgb(0 0 0 / 10%);
    box-sizing: border-box;
    overflow-y: auto;
    overflow-x: hidden;
    max-height: 175px;
    box-shadow: 0px 0px 14px 0px rgba(162, 162, 162, 0.5);
  }
  .groupUserList::-webkit-scrollbar {
    width: 6px;
  }
  .groupUserList::-webkit-scrollbar-track {
    border-radius: 10px;
  }
  .groupUserList::-webkit-scrollbar-thumb {
    background-color: #c1c1c1;
    border-radius: 20px;
  }
  .empty {
    font-size: 14px;
    padding: 0 20px;
    color: #999;
  }
  .item {
    font-size: 14px;
    padding: 0 0 0 5px;
    cursor: pointer;
    color: #333;
    display: flex;
    align-items: center;
    height: 35px;
    background: #fff;
    .avatar {
      flex-basis: 30px;
      img {
        width: 25px;
        height: 25px;
        object-fit: cover;
        vertical-align: bottom;
      }
    }
    &:hover,
    &.active {
      background: #ececee;
    }
    .id {
      font-size: 12px;
      color: rgb(83, 81, 81);
    }
  }
}
</style>

2.1.3 微信默认表情包弹窗组件(wxEmojisDialog.vue)

<template>
  <!-- 微笑表情包选择弹窗 -->
  <div class="wxEmojisDialog">
    <div class="pointIco"></div>
    <div class="wxEmojisList">
      <div
        class="wxEmojisItem"
        v-for="(item, index) of wxEmojisData"
        :key="index"
        @click="wxEmojisClick(item)"
        v-html="wxEmojis(item)"
      ></div>
    </div>
  </div>
</template>

<script>
import { mapGetters } from "vuex";
import { wxEmojis } from "@/utils/wxEmojis";
export default {
  components: {},
  data() {
    return {};
  },
  computed: {
    ...mapGetters(["wxEmojisData"]),
  },
  methods: {
    //微信默认表情包回显
    wxEmojis(html) {
      return wxEmojis(html);
    },

    //选择表情包
    wxEmojisClick(item) {
      console.log("item", item);
      this.$emit("wxEmojisClick", item);
    },
  },
  created() {},
};
</script>

<style lang="scss">
.wxEmojisDialog {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 100;
  margin-top: -303px;
  background: #fff;
  border-radius: 10px;
  margin-left: -150px;
  padding: 5px 2px 5px 5px;
  box-shadow: 0px 0px 14px 0px rgba(162, 162, 162, 0.5);
  .pointIco {
    position: absolute;
    bottom: 0;
    left: 50%;
    margin-left: -8px;
    margin-bottom: -16px;
    width: 0px;
    height: 0px;
    border-top: #fff 8px solid;
    border-left: transparent 8px solid;
    border-right: transparent 8px solid;
    border-bottom: transparent 8px solid;
  }
  .wxEmojisList {
    display: flex;
    flex-wrap: wrap;
    width: 358px;
    height: 282px;
    overflow-y: auto;
    border-radius: 10px;
    .wxEmojisItem {
      //微信默认表情
      width: 34px;
      height: 34px;
      display: flex;
      align-items: center;
      justify-content: center;
      margin: 5px;
      cursor: pointer;
      border-radius: 4px;
      .wxEmojis {
        display: inline-block;
        width: 24px;
        height: 24px;
        background: url("~@/assets/images/wxEmojis.png") top left
          no-repeat;
        // background-position: -3px -3px;
        vertical-align: bottom;
        margin: 0 1px;
        transition: 0.2s;
      }

      &:hover {
        background: #f2f2f2;
        .wxEmojis {
          transform: scale(1.1);
        }
      }
    }
  }
  .wxEmojisList::-webkit-scrollbar {
    width: 6px;
  }
  .wxEmojisList::-webkit-scrollbar-track {
    background-color: #fff;
    border-radius: 10px;
  }
  .wxEmojisList::-webkit-scrollbar-thumb {
    background-color: #c1c1c1;
    border-radius: 20px;
  }
}
</style>

2.2 表情包数据

2.2.1 store->modules->wxEmojisData.js

const state = {
  wxEmojisArr: [
    '[微笑]', '[撇嘴]', '[色]', '[发呆]', '[得意]', '[流泪]', '[害羞]', '[闭嘴]',
    '[睡]', '[大哭]', '[尴尬]', '[发怒]', '[调皮]', '[呲牙]', '[惊讶]', '[难过]',
    '[囧]', '[抓狂]', '[吐]', '[偷笑]', '[愉快]', '[白眼]', '[傲慢]', '[困]',
    '[惊恐]', '[憨笑]', '[悠闲]', '[咒骂]', '[疑问]', '[嘘]', '[晕]', '[衰]',
    '[骷髅]', '[敲打]', '[再见]', '[擦汗]', '[抠鼻]', '[鼓掌]', '[坏笑]', '[右哼哼]',
    '[鄙视]', '[委屈]', '[快哭了]', '[阴险]', '[亲亲]', '[可怜]', '[笑脸]', '[生病]',
    '[脸红]', '[破涕为笑]', '[恐惧]', '[失望]', '[无语]', '[嘿哈]', '[捂脸]', '[奸笑]',
    '[机智]', '[皱眉]', '[耶]', '[吃瓜]', '[加油]', '[汗]', '[天啊]', '[Emm]',
    '[社会社会]', '[旺柴]', '[好的]', '[打脸]', '[哇]', '[翻白眼]', '[666]', '[让我看看]',
    '[叹气]', '[苦涩]', '[裂开]', '[嘴唇]', '[爱心]', '[心碎]', '[拥抱]', '[强]',
    '[弱]', '[握手]', '[胜利]', '[抱拳]', '[勾引]', '[拳头]', '[OK]', '[合十]',
    '[啤酒]', '[咖啡]', '[蛋糕]', '[玫瑰]', '[凋谢]', '[菜刀]', '[炸弹]', '[便便]',
    '[月亮]', '[太阳]', '[庆祝]', '[礼物]', '[红包]', '[發]', '[福]', '[烟花]',
    '[猪头]', '[爆竹]', '[跳跳]', '[发抖]', '[转圈]'
  ]
}

export default {
  namespaced: true,
  state,
};

2.2.2 store->getters.js

const getters = {
  wxEmojisData: state => state.wxEmojisData.wxEmojisArr
}
export default getters

2.2.4 store->index.js

import Vue from 'vue'
import Vuex from 'vuex'
import getters from './getters'
import wxEmojisData from './modules/wxEmojisData' //微信默认表情包数据
Vue.use(Vuex)

export default new Vuex.Store({
  modules: {
    wxEmojisData
  },
  getters,
})

2.3 聊天框js

2.3.1 表情包回显js

实现思路:

  1. 以背景(雪碧图)的形式插入图片,
  2. 设置插入表情包元素的dom标签的width、height值,控制雪碧图的background-position属性来回显对应的表情包
  3. 表情包的数据格式为数组:['[微笑]', '[撇嘴]'...],是为了提交的时候可以提交对应的数据
  4. 表情包的回显是通过遍历的形式,将数组的每一项('[微笑]')替换成dom元素,找出表情包的布局规则,动态设置background-position值

表情包回显js

import store from '@/store';

//表情包回显
export function wxEmojis(str) {
  let iconArr = store.getters.wxEmojisData; //存放表情包的数组
  iconArr.forEach((item, index) => {
    if (str.indexOf(item) !== -1) {
      let bgLeft = "", //横坐标
        bgTop = "", //纵坐标
        bhPosition = "0 0",
        icoLen = 7, //表情包-行的个数
        icoHeight = 36; //表情包的高度   
      if (index < 7) {
        //第一列的表情包
        bgLeft = -(24 * index + (16.8 * index)) + 'px';
        bgTop = 0;
        bhPosition = bgLeft + ' ' + bgTop;
      } else if (index >= 7) {
        let icoColumn = parseInt(index / 7);//表情包第几列
        bgLeft = -(24 * (index - (icoColumn * icoLen)) + (16.8 * (index - (icoColumn * icoLen)))).toFixed(2) + 'px';
        bgTop = -(icoColumn * icoHeight) + 'px';
        bhPosition = bgLeft + ' ' + bgTop;
      }
      str = str.replaceAll(item, '<i class="wxEmojis" contenteditable="false" style="background-position:' + bhPosition + '" data-value=' + item + '></i>');
    }
  })
  return str;
}

2.3.2 聊天框公共js

//发送聊天信息-js
export default {
  trimHtml(str) {
    /**
     * 可编辑的div
     * 1.换行是通过给内容插入<div></div>标签;提交的时候要把<div></div>替换成换行符;
     * 2.空格是通过插入"&nbsp",提交的时候要转换成空格;
     * 3.提交数据的时候将html标签去掉,只保留里面的纯文本内容;
     */
    str = str.replaceAll("<div>", "\n");
    str = str.replaceAll("</div>", " ");
    str = str.replaceAll("<br>", " ");
    str = str.replaceAll("&nbsp; ", " ");
    str = str.replace(/<\/?[^>]*>/g, "");
    str = str.replace(/<[^>]*>/g, "");
    return str;
  },

  // 是否展示 @
  showAt() {
    const node = this.getRangeNode(); // 获取节点
    if (!node || node.nodeType !== Node.TEXT_NODE) return false;
    const content = node.textContent || "";
    const regx = /@([^@\s]*)$/;
    const match = regx.exec(
      content.slice(0, this.getCursorIndex())
    );
    return match && match.length === 2;
  },

  // @成员弹窗出现的位置
  getRangeRect(groupUserCount) {
    //console.log('@成员弹窗出现的位置-groupUserCount',groupUserCount)
    const selection = window.getSelection();
    const range = selection.getRangeAt(0); // 是用于管理选择范围的通用对象
    const rect = range.getClientRects()[0]; // 择一些文本并将获得所选文本的范围
    let LINE_HEIGHT = 180; //群成员占据的高度
    //判断群成员的个数
    if (groupUserCount < 4) {
      LINE_HEIGHT = groupUserCount * 36;
    } else {
      LINE_HEIGHT = 180;
    }
    // console.log('rect', rect)
    // console.log('rect.y',rect.y - LINE_HEIGHT)
    return {
      x: rect.x,
      y: rect.y - LINE_HEIGHT,
    };
  },

  // 获取 @ 用户
  getAtUser() {
    const content = this.getRangeNode().textContent || "";
    const regx = /@([^@\s]*)$/;
    const match = regx.exec(
      content.slice(0, this.getCursorIndex())
    );
    if (match && match.length === 2) {
      return match[1];
    }
    return undefined;
  },

  //选择表情包后在聊天框内插入html
  insertHtmlAtCaret(html, chatBlurIndex) {
    console.log("document.createRange()", document.createRange());
    var sel, range;
    if (window.getSelection) {
      // IE9 and non-IE
      sel = window.getSelection();
      if (sel.getRangeAt && sel.rangeCount) {
        if (!chatBlurIndex) {
          range = sel.getRangeAt(0);
        } else {
          range = chatBlurIndex;
        }
        range.deleteContents();
        // Range.createContextualFragment() would be useful here but is
        // non-standard and not supported in all browsers (IE9, for one)
        var el = document.createElement("div");
        el.innerHTML = html;
        var frag = document.createDocumentFragment(),
          node,
          lastNode;
        while ((node = el.firstChild)) {
          lastNode = frag.appendChild(node);
        }
        range.insertNode(frag);
        // Preserve the selection

        console.log("lastNode", lastNode);
        if (lastNode) {
          range = range.cloneRange();
          range.setStartAfter(lastNode);
          range.collapse(true);
          sel.removeAllRanges();
          sel.addRange(range);
        }
      }
      //this.chatTextareaClick();
    }
  },

  // 创建@成员标签
  createAtButton(user) {
    const atBtn = document.createElement("span");
    atBtn.style.display = "inline-block";
    //vertical-align: bottom;
    atBtn.dataset.user = JSON.stringify(user);
    atBtn.className = "at-button";
    atBtn.contentEditable = "false";
    atBtn.textContent = `@${user.nick_name}  `;
    return atBtn;
  },

  // 插入@标签
  replaceAtUser(replaceHtml, that) {
    const node = that.node;
    console.log("replaceHtml", replaceHtml);
    console.log("node", that.node);
    if (node && replaceHtml) {
      const content = node.textContent || "";
      const endIndex = that.endIndex;
      const preSlice = this.replaceString(
        content.slice(0, endIndex),
        ""
      );
      const restSlice = content.slice(endIndex);
      const parentNode = node.parentNode;
      const nextNode = node.nextSibling;
      const previousTextNode = new Text(preSlice);
      const nextTextNode = new Text("\u200b" + restSlice); // 添加 0 宽字符
      let atButton;
      atButton = this.createAtButton(replaceHtml);
      if (node) {
        parentNode.removeChild(node);
      }

      // 插在文本框中
      if (nextNode) {
        parentNode.insertBefore(previousTextNode, nextNode);
        parentNode.insertBefore(atButton, nextNode);
        parentNode.insertBefore(nextTextNode, nextNode);
      } else {
        parentNode.appendChild(previousTextNode);
        parentNode.appendChild(atButton);
        parentNode.appendChild(nextTextNode);
      }

      // 重置光标的位置
      const range = new Range();
      const selection = window.getSelection();
      range.setStart(nextTextNode, 0);
      range.setEnd(nextTextNode, 0);
      selection.removeAllRanges();
      selection.addRange(range);
    }
  },

  // 获取光标位置
  getCursorIndex() {
    const selection = window.getSelection();
    return selection.focusOffset; // 选择开始处 focusNode 的偏移量
  },

  // 获取节点
  getRangeNode() {
    const selection = window.getSelection();
    return selection.focusNode; // 选择的结束节点
  },

  //将@替换
  replaceString(raw, replacer) {
    return raw.replace(/@([^@\s]*)$/, replacer);
  }

}