万字实现带@和表情包的输入框

1. 前言

最近刷到几篇实现带@的输入框,正好最近也有类似沾边的需求,于是笔者顺手研究了一下如何实现带@的输入框(可编辑div)

实现一个支持@的输入框 实现一个带@功能的输入框组件

本文在参考上面文章的基础上,结合两者优点和自己一些想法改造优化,自己实现了一版带@的输入框,文章较长,创作不易,大家多多支持。话不多说,直接上整体效果 主要功能如下:唤起列表,选中输入表情包输入预览回显支持复制粘贴

2.功能开发

2.1 @唤起列表,键盘控制列表选中移动

2.1.1 效果预览
2.1.2 功能难点
  • 实现监测@然唤起用户列表需要实时检测用户在光标范围内的输入,提取关键字进行模糊搜索匹配,同时定位到光标位置唤起用户列表;
  • 唤起用户列表后,需要支持键盘上下键移动,始终让选中的人展示在视图中;
  • 选择用户后,需要把用户填到光标处正确位置同时进行内容替换,这里面涉及到如何在输入框失焦后点击用户还能正确记住上次光标位置;
  • 对于创建的用户@标签,如何实现一键删除以及丝滑选中。
2.1.3 前置

首先我们知道这是一个可编辑的div(设置了contenteditable="true"属性)才能进行富文本的输入,我们先做一些基础的了解先,根据MDN描述,

selection 通过window.getSelectioin()得到,selection 对象表示用户选择的文本范围或插入符号的当前位置,它代表页面中的文本选区,文本选区由用户拖拽鼠标经过文字而产生
Range范围指的是文档中连续的一部分,通过selection.getRangeAt(index=0)得到。一个范围包括整个节点,也可以包含节点的一部分,例如文本节点的一部分,用户通常下只能选择一个范围,但是有的时候用户也有可能选择多个范围(例如当用户按下 Control 按键并框选多个区域时,Chrome 中禁止了这个操作)。

Range可以理解为比selection对象更精细化的操作,本文基于Range对象支持实现。

在图中的输入框中,我们可以看到大致有3个节点(多了一个换行符文本就不管先),这时光标闪烁位置是在字后面,打印Range对象可以知道,它的startContainer就是容器text-1,偏移量startOffset表示range在这个startContainer的起始位置(第4个字符),同理endOffset也一样,这里和startOffset相同是因为开始容器和结束结束容器指向同一个节点,并且鼠标没有选中一片区域(托蓝),所以两者相等,否则根据拖蓝区域和拖蓝方向来算。

2.1.4 怎么样提取@后面的模糊匹配用户名呢?

在这个输入框input事件绑定了matchAt这个函数,在这里先定义一个变量caretBeforeSre来存range.startContainer(当前光标所在的起始容器节点中)中截取到光标所在的startOffset的字符串,然后从后往前找最后一个@号,通过caretBeforeStr.slice(lastAtIndex + 1)来就获取到了searchName,通过getUserList函数模拟后端返回匹配结果。getRange这个函数会多次用到,这里封装了一下

typescript 复制代码
export function getRange(): Range | null {
  const selection = window.getSelection();
  if (selection && selection.rangeCount > 0) {
    const range = selection.getRangeAt(0);
    return range;
  }
  return null;
}
function matchAt(): void {
  // editDom为外层的可编辑div
  const editDom = EditContainer.value as HTMLElement;
  // 判断是否显示默认占位符('留下你的想法吧'这个div)
  showCalDefault.value = !editDom.textContent;
  let caretBeforeStr = '';
  const range = getRange();
  if (range) {
    // 判断光标所在是不是text节点,不然删出span标签右侧元素后range.startContainer会是父元素,触发@判断
    if (!(range.startContainer instanceof Text)) return;
    // 截取光标所在起始文本容器位置字符串到光标当前闪烁点处的字符串
    caretBeforeStr = range.startContainer.textContent?.slice(0, range.startOffset) || '';
    // 从后往前寻找最近一个@符号
    const lastAtIndex = caretBeforeStr.lastIndexOf('@');
    if (lastAtIndex !== -1) {
      // 截取@之后的字符
      const searchName = caretBeforeStr.slice(lastAtIndex + 1);
      // 判断是否含有空格,有空格认为不at了
      if (searchName.includes(' ')) {
        curAtIndex = -1;
        return;
      }
      // 保存当前@所在光标容器内的索引
      curAtIndex = lastAtIndex;
      // 设置用户列表唤起位置,同时唤起用户列表
      const { left, top } = getAtListPosition(range);
      tipsAtmention.positionLeft = left;
      tipsAtmention.positionTop = top;
      tipsAtmention.showTips = true;

      // 模糊查询匹配用户名
      getUserList(searchName).then((res) => {
        tipsAtmention.tipsArr = res;

        if (res.length > 0) {
          selectActiveIndex.value = Math.min(tipsAtmention.tipsArr.length - 1, selectActiveIndex.value);
        } else {
          tipsAtmention.showTips = false;
        }
      });
    } else {
      tipsAtmention.showTips = false;
      curAtIndex = -1;
    }
  }
};
2.1.5 怎么在光标出弹出用户选项列表呢?

在上面的matchAt函数里面有个setAtListPosition函数,这就是用户在指定位置谈起列表的,通过Range对象的getBoundingClientRect函数可以获取到光标的视窗坐标,我们给用户列表设置绝对定位(输入框的外层设置相对定位),这样一来用户列表的left就是left - eLeft,top就是top - eTop,不过top的最小值应当大于0,最大值应当小于外层的高度,才能位置始终在框内。

typescript 复制代码
function getAtListPosition(range: Range): { left: number; top: number } {
  // 获取光标位置相对于视口的偏移量

  const { left, top } = range.getBoundingClientRect();
  // console.log('left , top', `${left} , ${top}`);

  const editDom = EditContainer.value as HTMLElement;
  const { left: eLeft, top: eTop } = editDom.getBoundingClientRect();
  const basicHeight = editDom.clientHeight;
  // console.log('eLeft , eTop', `${eLeft} , ${eTop}`);
  return {
    left: left - eLeft,
    top: Math.max(0, Math.min(top - eTop, basicHeight)),
  };
};
2.1.6 怎么控制在列表上的键盘上下移动控制?

首先我们需要检测键盘输入上下键,他们key值为ArrowDownArrowUp,并初始化一个选中索引selectActiveIndex,当向上时索引++,向下时索引--.同时给用户列表选项每个item设置背景色选中时在添加(下面是外层tips容器和选项item的html结构)

html 复制代码
<div
  v-if="tipsAtmention.showTips"
  ref="CalTips"
  class="cal_tips"
  :style="{ top: tipsAtmention.positionTop+'px',left:tipsAtmention.positionLeft+'px'}"
  @click="tipsModelStop"
>
  <div
    class="cal_tips-wrapper"
  >
    <div
      v-for="(item,index) in tipsAtmention.tipsArr"
      :key="item.id"
      class="cal_tips-wrapper-item"
      :style="{backgroundColor:selectActiveIndex==index?'#e2eefa':''}"
      @click="handleChooseUser(index)"
    >
<!-- 占位内容 -->
  </div>
</div>

现在看着挺完美的,但还有问题,假设我们先按下键盘,一直往下直到看不到选中的item,滚动一段距离(滑动太少了),此时就会发现,选中的值没有定位到视野中,看来不太简单,得解决,聪明的你就会想到。下面这张图(基线是指完全展开各项item元素高度的顶部)。

此时选项item处于看得到这种状态(但是又不完全),这时就会发现如果我们右边这两段长度加起来(表示item底部到基线的距离,一出生就根据位置固定了 )如果大于左边两根加起来(表示处于容器窗口内最大距离)的化,那就会看不到或者完全看不到。看图中,可调整的只剩下scrollTop了,剩下三个都是只读属性。那么聪明的你会想到让两段线相等不就行了,是的! 同理,滑过头也一样分析,当滚动距离大于自身offsetTop,这就是滑过头了,只需要要滚动距离等于当前offsetTop即可。

typescript 复制代码
function hanldeMove() {
  // 获取外层容器
  const WrapperEle = CalTips.value as HTMLElement;
  const ItemEleArr = WrapperEle.querySelectorAll('.cal_tips-wrapper-item');
  // 或许当前选中的item
  const ItemEle = ItemEleArr[selectActiveIndex.value] as HTMLElement;
  const itemTop = ItemEle.offsetTop;
  const itemHeight = ItemEle.offsetHeight;
  const wrapperHeight = WrapperEle.clientHeight;
  const wrapperTop = WrapperEle.scrollTop;

  if (wrapperHeight + wrapperTop <= itemTop + itemHeight) {
    // 滑的太少了
    WrapperEle.scrollTop = itemTop + itemHeight - wrapperHeight;
  }
  if (itemTop < wrapperTop) {
    // 滑过头了
    WrapperEle.scrollTop = itemTop;
  }
};

const triggerMatchKeyList = ['ArrowLeft', 'ArrowRight'];
const interceptkeyList = ['ArrowUp', 'ArrowDown', 'Enter'];

function hanldeKeyDown(e: KeyboardEvent): void {
  // 判断是否是左右箭头,触发匹配@
  if (triggerMatchKeyList.includes(e.key)) {
    // 光标还未更新,光标更新后再触发
    setTimeout(() => {
      matchAt();
    });
    return;
  }
  // 判断用户列表展示且键盘值为 上下箭头和回车
  if (!tipsAtmention.showTips || !interceptkeyList.includes(e.key)) return;
  e.preventDefault();
  e.stopPropagation();

  switch (e.key) {
    // eslint-disable-next-line @stylistic/quotes
    case "ArrowDown":
      if (selectActiveIndex.value + 1 < tipsAtmention.tipsArr.length) {
        selectActiveIndex.value++;
        hanldeMove();
      }
      break;
    case 'ArrowUp':
      if (selectActiveIndex.value - 1 >= 0) {
        selectActiveIndex.value--;
        hanldeMove();
      }
      break;
    case 'Enter':
      EditContainer.value?.blur();
      handleChooseUser(selectActiveIndex.value);
      break;
  }
};

需要注意的是,如果弹出用户列表,我们键盘操作上下箭头和enter键,是需要阻止默认事件和冒泡的 ,走对应的自定义逻辑;另外这时候点左右箭头会在光标会在输入框内改变位置,之前记录@的位置就不对了,会出bug报错,为了避免这操作,需要对左右箭头单独处理,按下后再光标位置更新后触发matchAt函数来检测是否有@号。

2.1.7 怎么实现点击用户在输入框创建标签

现在万事俱备,回归本身,我们怎么实现选中item点击然后添加到输入框里面呢? 有同学说:很简单啊,获取当前光标位置,然后对输入@后的字符长度匹配替换就行了。emmmm,其实也没错,道理也就是这个道理,但实践起来总会遇到一堆事。 我们来分析一些,点击选项item会发生什么?

  • 正在闪烁光标的输入框触发失焦事件
  • 触发item点击事件

这么看失焦光标先没了啊,到第二步你敢相信你拿到的光标Range范围对象还是刚刚输入的那块文本吗。因此,首先我们需要在失焦时保存当前range对象(利用Range.cloneRange克隆一个)到curCalRange这个常量中

typescript 复制代码
function calBlur(): void {
  const range = getRange();
  if (range) {
    curCalRange = range.cloneRange();
    // console.log('失焦', range.cloneRange());
  }
};

等触发item点击回调时,我们就可以取出这个curCalRange,把Range对象定位到这个curCalRange来,接下来就是精细的操作。

  • 首先跟进item选项获取里面的user信息,通过Range.setStartRange.setEnd选中@后面到userName长度的字符,通过Range.deleteContents()进行删除。
typescript 复制代码
  const len = Number(curCalRange.startContainer.textContent?.length);
  const endLen = Math.min(curAtIndex + user.name.length + 1, len);
  // range在哪不重要,重要的是caretMark.startContainer在哪,选中的就是这个节点
  range.setStart(curCalRange.startContainer, curAtIndex);
  range.setEnd(curCalRange.startContainer, endLen);
  range.deleteContents();
  • 接下来就是插入指定的userName结构了。通过设置一个span标签配置contentEditable=false可以实现一键删除,为了标记这个特殊节点和双向验证(2.3用到),我们需要再加一个dataSet属性为当前用户id(2.32.4 用到)。创建完后可以通过Range.insertNode来插入这个span标签
  • 不过呢,还有一个问题,如果只做到上面这步你会发现鼠标是选中不了这个span标签的,这时候就需要优化,还需要再span标签两边插入空白字符(两个文本标签)
  • 或许再想到,插入文本标签还占位dom文档,插入伪元素然后设置占位符不行吗?当然可以,这样针对一个span标签就可以实现前后选中,但是如果这一行不止就他一个元素,比如两三个span标签,你就会发现,怎么只选得到自己,这样后面实现复制粘贴就难搞了~

这一步代码如下:

typescript 复制代码
function handleChooseUser(index: number): void {
  const user = tipsAtmention.tipsArr[index];
  const range = getRange();
  if (curAtIndex !== -1 && curCalRange && range) {
    const len = Number(curCalRange.startContainer.textContent?.length);
    const endLen = Math.min(curAtIndex + user.name.length + 1, len);
    // range在哪不重要,重要的是caretMark.startContainer在哪,选中的就是这个节点
    range.setStart(curCalRange.startContainer, curAtIndex);
    range.setEnd(curCalRange.startContainer, endLen);
    range.deleteContents();
    const prefixText = new Text('\u00A0');
    const subffixText = new Text('\u00A0');
    //createCalTag 详情移动到章节 2.3.4
    const eleNode = createCalTag({
      tag: CalTag.SPAN,
      attributes: {
        'data-user-id': user.id,
        'contentEditable': false,
      },
      className: 'cal_view-tag',
      content: '@' + user.name,
    });
    // 插入后缀占位节点,解决@xxx无法从右往左选中
    range.insertNode(prefixText);
    range.setStartAfter(prefixText);
    range.insertNode(eleNode);
    range.setStartAfter(eleNode);
    // 插入后缀占位节点,解决@xxx无法从左往右选中
    range.insertNode(subffixText);
    range.setStartAfter(subffixText);
    range.collapse();
    // 更新保存当前range
    curCalRange = range.cloneRange();
    // 聚焦输入框
    EditContainer.value?.focus();
    selectActiveIndex.value = -1;
    tipsAtmention.showTips = false;
    curAtIndex = -1;
  }
};

2.2 表情包输入

其实表情包输入和插入@用户标签的逻辑差不多,要注意下失焦和聚焦,先看效果

2.2.1 如何实现表情包创建?

首先点击输入框右下角表情会弹出表情框,同时聚焦当前输入框

typescript 复制代码
  function triggerEmoji() {
    showEmoji.value = true;
    EditContainer.value?.focus();
  };

我们在上一节实现了calBlur失焦事件后保存当前Range的克隆对象在curCalRange变量,现在顺便完善一个输入框聚焦事件,获取当前curCalRange对象然后进行选中范围。

typescript 复制代码
function calFocus() {
  const range = getRange();
  if (range && curCalRange) {
    range.setStart(curCalRange.startContainer, curCalRange.startOffset);
    range.setEnd(curCalRange.endContainer, curCalRange.endOffset);
  }
};

接着是点击具体表情,这里相比创建@xxx的span标签就简单了一点,设置为contentEditable=false的span标签为了实现前后选中还在其前后插入了两个占位的text节点,但是呢我们的img标签自己就可以选中,就不用这么麻烦了。由于有可能点击表情包空白出导致失焦,我们现需要令光标先闪烁回来聚焦。接着根据根据选中索引获取到对应的表情,然后创建img表情,配置src和dataSet属性(2.32.4 会用到)标记,然后通过createTag这个函数创建一个img元素插入到当前光标处。

html 复制代码
  <div
    v-if="showEmoji"
    class="cal_emoji-container"
  >
    <div
      v-for="(item,index) in tipsEmojiList"
      :key="item.id"
      class="cal_emoji-container-item"
      @click="chooseEmoji(index)"
    >
      <img :src="item.url" alt="">
    </div>
  </div>
typescript 复制代码
function chooseEmoji(index: number) {
  EditContainer.value?.focus();
  const emoji = tipsEmojiList.value[index];
  //createCalTag 详情移动到章节 2.3.4
  const imgTag = createCalTag({
    tag: CalTag.IMG,
    attributes: {
      'src': emoji.url,
      'data-img-name': emoji.name,
    },
    className: 'cal_emoji-img',
    //占位符,影响输入框控制判断显示默认内容
    content: ' ',
  });
  const range = getRange();
  if (range) {
    range.deleteContents();
    range.insertNode(imgTag);
    range.setStartAfter(imgTag);
    range.collapse();
    curCalRange = range.cloneRange();
    //关闭emoji列表
    showEmoji.value = false;
    showCalDefault.value = false;
  }
};

2.3 预览回显(数据结构封装和解析)

在前面两节我们实现了@用户输入和表情包输入,那么怎么传输到后端保存下来呢,又怎么回显呢?这里面确实存在一定的难点。调研了某书做法是把匹配的用户信息传给后端,下次回显时后端会返回剩下的用户名userName,接着对文本进行username匹配替换加样式之类的,但是存在一个问题,像这样如果一个正常@,但是一个是普通文本,某书回显到评论区是会两个都会亮起来的,不知道算不算bug。 接着是笔者实现的,效果速看

2.3.1 前后端传参字段

首先可以确定的是不可能直接传html。针对可能存在手动回车enter键换行的情况(在容器内创建了新的div)和精确匹配有效的@用户和表情包,亦或二次编辑的需求,笔者觉得用这样的结构处理(至少现在看着没出bug的方法),四个字段:

  • rootNode(传递和回显):表示该输入内递归遍历形成的简化自定义的树状结构(传递可以JSON.stringify一下)
  • verifiedArr(传递): 表示带有特殊信息的标签(@用户和表情包)的信息,后端校验是否匹配
  • content(传递):表示整个输入框内容,可能用于审核之类的(根div的textContent)
  • matchResult(回显):校验verifiedArr内成功的节点标记将会存下来,前端结合rootNode内的标志和matchResult对比再渲染

当我们点击发送时,将以输入框为根节点进行递归遍历每个节点对其进行处理,程序只允许4中类型节点(node.nodeName)解析,DIVSPANIMG#text,其他类型视作文本节点。目标是生成treeNode结构

typescript 复制代码
export interface TreeOption {
  nodeId: number;
  nodeName: string;
  textContent: string;
  children: TreeNode[];
  attributes: Record<string, unknown>;
}
export class TreeNode {
  // 节点标识,由nodeIdGenerater生成
  public nodeId;
  // 节点名称,node.nodeName
  public nodeName;
  // 节点内容,node.textContent
  public textContent;
  // 孩子节点
  public children;
  // 节点attributes属性,只会筛选有必要的
  public attributes;

  constructor(option: TreeOption) {
    this.nodeId = option.nodeId;
    this.nodeName = option.nodeName;
    this.textContent = option.textContent;
    this.attributes = option.attributes || {};
    this.children = option.children;
  }
}
2.3.2 如何将html转treeNode?

在每次判断节点之前,先创建一个rootNode节点

typescript 复制代码
  const rootNode = new TreeNode(
      {
        nodeId: nodeIdGenerater.getNodeId(),
        nodeName: node.nodeName,
        textContent: node.textContent || '',
        children: [],
        attributes: {},
      },
    );

对于span节点,需要获取它身上之前设置的data-user-id属性,如果span节点的node.textContent(用户名)和data-user-id(用户标识)同时存在的话,才确定当前节点有效,否则降级为text节点(设置rootNode.nodeName#text);同理,img节点也一样,判断它的srcdata-img-name同时存在才认定有效。 有同学就问了:这么麻烦,为啥要判断有效?其实一般都是有效的,但是如果有人按下F12修改dom结构把我们对节点标记改了的话,比如 @的名字没改 但是data-user-id删掉了,那这个用户唯一标识没了,系统咋判断到底是谁,总不能直接根据用户名判断吧,因此这个判断两者同时存在才认定由有效节点。 还有同学问:如果又有人按F12改了@用户名但是data-user-id没改,两者对不上咋办?欸,确实存在这种情况,这种情况下最好是通过后端判断了,这时我们在这一节定义的verifiedArr这个字段就发力了,遇到特殊节点(spanimg)需要将当前验证节点的信息发送给后端,给后端校验(校验正常,则打通内部通知到相应的@用户),前端根据后端返回一个matchResult这个节点id数组来判断那些节点有效来回显

typescript 复制代码
case 'SPAN':
        {
          const spanNode = node as HTMLSpanElement;
          const atUserId = spanNode.attributes.getNamedItem('data-user-id')?.value;
          const nodeContent = rootNode.textContent;
          if (atUserId && nodeContent) {
            rootNode.textContent = nodeContent.trim();
            rootNode.attributes = { 'data-user-id': atUserId, contenteditable: false };
            toVerifyDataArr.push({
              tagName: CalTag.SPAN,
              nodeId: rootNode.nodeId,
              targetId: atUserId,
              targetValue: nodeContent.slice(1).trim(),
            });
          } else {
            rootNode.nodeName = '#text';
          }
        }
        break;

对于div节点,作用就是一个容器,需要递归遍历它的childNodes,将遍历生成的节点push进自己的rootNode的children数组,需要优化的话,我们在章节2.1最后那里,为了让span节点实现选中,需要在span节点两边插入占位符的text文本节点,如果像下面这样 看着只有三个整体文本节点,实际上生成了5个文本节点,树的结构也会复杂一点,那么是不是可以优化,将相邻的文本节点合并起来,想图中圈起来一样,最后只有三个文本节点。具体实现可以用一个变量lastTextNode来存放前置文本节点(类似于慢指针),如果判断遍历到的当前节点时文本节点且与lastTextNode相邻(索引值相差1),就进行合并textContent,同时更新当前lastTextNode索引。整体代码如下:

typescript 复制代码
export function parseNode(node: Node): { toVerifyDataArr: ToVerifyData[]; rootNode: TreeNode } {
  const toVerifyDataArr: ToVerifyData[] = [];
  // 限制nodeType为 Element元素和文本节点
  const limitNodeTypeArr = [1, 3];
  nodeIdGenerater.init();
  const generateTree = (node: Node): TreeNode => {
    const rootNode = new TreeNode(
      {
        //前端生成nodeId,确保唯一即可
        nodeId: nodeIdGenerater.getNodeId(),
        nodeName: node.nodeName,
        textContent: node.textContent || '',
        children: [],
        attributes: {},
      },
    );
    if (!limitNodeTypeArr.includes(node.nodeType)) {
      rootNode.textContent = '';
    }
    switch (node.nodeName) {
      case 'DIV':
        {
          const childrenNodeList = node.childNodes;

          if (childrenNodeList.length > 0) {
            // lastTextNode用于合并相邻的text节点
            let lastTextNode: TreeOption & { index: number } | null = null;
            for (let i = 0; i < childrenNodeList.length; i++) {
              const childNode = childrenNodeList[i];
              const generatedNode = generateTree(childNode);
              if (generatedNode.nodeName === '#text') {
                if (!lastTextNode || i - lastTextNode.index !== 1) {
                  lastTextNode = { ...generatedNode, index: i };
                } else {
                  lastTextNode.nodeId = generatedNode.nodeId;
                  lastTextNode.textContent += generatedNode.textContent;
                  lastTextNode.index = i;
                }
                if (i === childrenNodeList.length - 1) {
                  // eslint-disable-next-line @typescript-eslint/no-unused-vars
                  const { index: _, ...resNode } = lastTextNode;

                  rootNode.children.push(resNode);
                }
              } else {
                if (lastTextNode) {
                  // eslint-disable-next-line @typescript-eslint/no-unused-vars
                  const { index: _, ...resNode } = lastTextNode;

                  rootNode.children.push(resNode);
                  lastTextNode = null;
                }
                rootNode.children.push(generatedNode);
              }
            }
          }
        }
        break;
      case 'SPAN':
        {
          const spanNode = node as HTMLSpanElement;
          const atUserId = spanNode.attributes.getNamedItem(SPAN_DATA_SET)?.value;
          const nodeContent = rootNode.textContent;
          if (atUserId && nodeContent) {
            rootNode.textContent = nodeContent.trim();
            rootNode.attributes = { [SPAN_DATA_SET]: atUserId, contenteditable: false };
            toVerifyDataArr.push({
              tagName: CalTag.SPAN,
              nodeId: rootNode.nodeId,
              targetId: atUserId,
              targetValue: nodeContent.slice(1).trim(),
            });
          } else {
            rootNode.nodeName = '#text';
          }
        }
        break;
      case 'IMG':
        {
          const imgNode = node as HTMLImageElement;
          const imgSrc = imgNode.attributes.getNamedItem('src')?.value;
          const imgName = imgNode.attributes.getNamedItem(IMG_DATA_SET)?.value || '';
          rootNode.textContent = generateImgName(imgName);
          if (imgSrc && imgName) {
            rootNode.attributes = { src: imgSrc, [IMG_DATA_SET]: imgName };
            toVerifyDataArr.push({
              tagName: CalTag.IMG,
              nodeId: rootNode.nodeId,
              targetId: imgSrc,
              targetValue: imgName,
            });
          } else {
            rootNode.nodeName = '#text';
          }
        }
        break;
      default: {
        rootNode.nodeName = '#text';
      }
    }
    return rootNode;
  };
  const rootNode = generateTree(node);
  console.log('生成的rootNode', rootNode);

  return {
    toVerifyDataArr, rootNode,
  };
};
2.3.3 后端如何校验(数据写死的简化版)

这里简单模拟了后端校验,需要对前端传过来的verifiedArr数组遍历每个元素,遇到@用户的span节点就校验用户名和id是否对的上,对的上就执行逻辑调用通知服务,遇到img标签就判断src和name对不对的上(name代表表情包的含义),同理;对的上的话,就把当前元素的nodeId push进matchResult这个数组返回。不过需要把treeNodematchResult两个字段存表。 或许后端还可以直接遍历修正treeNode的值,这样就不用存下来matchResult这个字段,前端回显时也不用根据matchResult来判断那些节点校验成功有效,直接无脑遍历treeNode生成html结构即可,这可能比较合理一些。这里笔者还是用了matchResult这种方法,不是有多好,而是懒,单纯不想再改代码动逻辑了(😂)

typescript 复制代码
export function getTreeData(option: submitOption): Promise<{
  matchResult: number[];
  rootNode: TreeNode | null;
}> {
  console.log('接收到请求', option);

  const { verifiedArr, rootNode, content } = option;

  const res: number[] = [];
  verifiedArr.forEach((item) => {
    if (item.tagName === CalTag.SPAN) {
      const user = userAllList.find((ele) => ele.id === item.targetId);

      if (user && user.name === item.targetValue) {
        res.push(item.nodeId);
      }
    }
    if (item.tagName === CalTag.IMG) {
      const emoji = emojiList.find((ele) => ele.url === item.targetId);
      if (emoji && emoji.name === item.targetValue) {
        res.push(item.nodeId);
      }
    }
  });
  // 对content审核,敏感词汇等等...,这里就随意简化一下,判空
  return new Promise((resolve) => {
    resolve({
      matchResult: content ? res : [],
      rootNode: content ? rootNode : null,
    });
  });
}
2.3.4 前端回显,根据treeNode结构生成html

上面2.3.3是根据html生成treeNode,同样的逻辑,我们直接配合matchResult解码即可

typescript 复制代码
export function generateNode(rootNode: TreeNode, validNodeIdArr?: number[]): HTMLElement | Text {
  switch (rootNode.nodeName) {
    case 'DIV':
    {
      const divDom = document.createElement('div');
      const childNodeList = rootNode.children;
      if (childNodeList.length > 0) {
        for (let i = 0; i < childNodeList.length; i++) {
          const childNode = childNodeList[i];
          divDom.appendChild(generateNode(childNode, validNodeIdArr));
        }
      }
      return divDom;
    }
    case 'SPAN':
    {
      const tagOption: CreateTagOption = {
        tag: CalTag.SPAN,
        attributes: rootNode.attributes,
        className: 'cal_view-tag',
        content: rootNode.textContent,
      };
      //校验失败降级为文本节点
      if (validNodeIdArr && !validNodeIdArr.includes(rootNode.nodeId)) {
        tagOption.tag = CalTag.TEXT;
      }
      const spanDom = createCalTag(tagOption);
      return spanDom;
    }
    case 'IMG':
    {
      const tagOption: CreateTagOption = {
        tag: CalTag.IMG,
        attributes: rootNode.attributes,
        className: 'cal_emoji-img',
        content: rootNode.textContent,
      };
      if (validNodeIdArr && !validNodeIdArr.includes(rootNode.nodeId)) {
        tagOption.tag = CalTag.TEXT;
      }
      const imgDom = createCalTag(tagOption);
      return imgDom;
    }
    default: {
      return createCalTag({ tag: 'text', content: rootNode.textContent });
    }
  }
};

之前我们在2.1.72.2.1都用到了createCalTag这个函数,主要用于创建dom元素,把可配置的属性加到上面

typescript 复制代码
export function createCalTag(option: CreateTagOption): HTMLElement | Text {
  if (option.tag === 'text') {
    const content = option.content || '';
    return new Text(content);
  }
  // 普通element元素
  const ele = document.createElement(option.tag);
  if (option.className) {
    ele.className = option.className;
  }
  if (option.attributes) {
    Object.entries(option.attributes).forEach(([key, value]) => {
      if (value !== undefined && value !== null) {
        ele.setAttribute(key, String(value));
      }
    });
  }
  if (option.content) {
    ele.textContent = `${option.content}`;
  }
  return ele;
};

可视化如下图:

2.4 支持复制粘贴

我们先看一下不限制复制粘贴事件的样子,这里可以直接把下面dom结构都粘贴上上来了,点击发送时,由于我们generateNodeparseNode两个函数的限制,才得以使未知结构正常 如果我们不想用户这么乱搞呢,花里胡哨的内容粘贴过来直接被我们格式化。其实这和功能2.3差不多,监听拦截copypaste事件,使用generateNodeparseNode两个函数解析即可。 我们给输入框监听copy事件,使用range.cloneContents获取当前选区的DocumentFragment的html结构,剪贴板对象通过setData设置属性text/html为当前元素html结构。代价是啥呢?我们阻止了浏览器在这个输入框的复制粘贴默认事件,就不会将我们自定义的修改逻辑记录到历史栈中,粘贴后ctrl+z操作的不是当前这个粘贴记录,maybe真要搞比较复杂,这里就步兼容了。

typescript 复制代码
 calEle.addEventListener('copy', (e: ClipboardEvent) => {
    e.preventDefault();
    const range = getRange();
    if (range) {
      const container = document.createElement('div');
      container.appendChild(range.cloneContents());
      e.clipboardData?.setData('text/html', container.innerHTML);
      e.clipboardData?.setData('text/plain', container.textContent || '');
    }
  });

psate事件中,通过clipboardData?.getData('text/html')获取当复制的html内容,需要注意的是,浏览器会从剪贴板获取的 text/html格式内容常常会被自动包裹 <!--StartFragment--><!--EndFragment--> 这两个注释标签,这是浏览器的默认行为。我们需要提取出注释节点包裹的内容即可,之后就是对html结构进行parseNodegenerateNode,对生成的node节点进行遍历进DocumentFragment节点中,通过range.insertNode(fragment)插入到光标处。

typescript 复制代码
 calEle.addEventListener('paste', (e) => {
    e.preventDefault();
    const range = getRange();
    if (range) {
      const clipboardData = e.clipboardData;
      const pastedHtml = clipboardData?.getData('text/html') || clipboardData?.getData('text/plain') || '';
      console.log('pastedHtml', pastedHtml);
      // 浏览器默认包裹两个注释节点,需要提取包裹的字符串
      const regex = /<!--StartFragment-->([\s\S]*?)<!--EndFragment-->/;
      const match = pastedHtml.match(regex);

      // 提取有效内容(如果匹配成功则取中间部分,否则使用原始内容)
      const rawHtml = match ? match[1] : pastedHtml;
      const container = document.createElement('div');

      container.innerHTML = rawHtml;
      const { rootNode } = parseNode(container);

      const newContainer = generateNode(rootNode);
      const fragment = document.createDocumentFragment();
      // 不能使用for循环从前往后遍历,因为如果文档中已存在给定节点,则将其从当前位置移动到新位置,节点会移动
      while (newContainer.firstChild) {
        fragment.appendChild(newContainer.firstChild);
      }

      const lastChild = fragment.lastChild;
      range.deleteContents();
      range.insertNode(fragment);
      if (lastChild) {
        range.setStartAfter(lastChild);
        showCalDefault.value = false;
      }

      range.collapse(true);
    }
  });

最后效果如下:

3.总结

项目用了vue3+ts实现,代码也有一些地方存在一定的局限性,不够规范,欢迎大家交流。 完整源码地址: github.com/pch723/Mult... gitee.com/send-ch/Mul... 文章转载请注明原文

相关推荐
幽络源小助理6 小时前
SpringBoot基于Mysql的商业辅助决策系统设计与实现
java·vue.js·spring boot·后端·mysql·spring
鱼樱前端9 小时前
今天介绍下最新更新的Vite7
前端·vue.js
炒毛豆11 小时前
vue3.4中的v-model的用法~
前端·vue.js
阳火锅11 小时前
都2025年了,来看看前端如何给刘亦菲加个水印吧!
前端·vue.js·面试
夕水12 小时前
ew-vue-component:Vue 3 动态组件渲染解决方案的使用介绍
前端·vue.js
codehub12 小时前
TypeScript 高频面试题与核心知识总结
typescript
我麻烦大了12 小时前
实现一个简单的Vue响应式
前端·vue.js
aklry13 小时前
uniapp三步完成一维码的生成
前端·vue.js
张志鹏PHP全栈13 小时前
TypeScript 第一天,认识TypeScript
typescript
用户261245834016115 小时前
vue学习路线(11.watch对比computed)
前端·vue.js