js纯手写富文本的@功能

难点:

  • 1.在定义@标签时,如果使用不闭合的标签,那么会造成标签会继承父组件的可编辑属性contentEditable,从而形成标签套标签的形式. 后果:这会造成最后在提交聊天记录过滤标签时,很不友好,因为它继承了可编辑属性contentEditable,我嵌套在内的@标签通过正则匹配出来是有问题的,我写的正则匹配不到我想要的所带要求的属性标签,我在正则在线测试工具中测试了很多中匹配方法,可能是我正则用的不熟?
    解决:我用了一个巧办法就是input标签,type为button,disabled为true,然后把边框等全部用css去隐藏。替换其他标签非常完美。这样我就不会造成标签套标签了。最后提交时匹配所有input标签,但专门查找到含有我自定义属性的input,然后过滤出我要的userid和value,其余不含有自定义属性的全部过滤标签,只留下value值,json串的type为text进行提交。

  • 2.我最开始在定义@标签时,我没有使用闭合标签,这造成两个很严重的后果,一个是1.中已经说过的问题,如果我既要使用不闭合的i标签,又要它不输入就要把contentEditable设置为false,这样就是我要说的第二个严重后果,就是光标定位的问题,如果是单独的pc端没问题,它不会有光标定位到最后的问题,我们这个项目是pc+客户端,客户端用的是electron,所以我们会有这样的问题,排查问题就是我感觉它不兼容或者是光标定位到了可编辑标签外了。如果和我一样用了1.的巧办法就不用管2.了,我这里只是为了做总结,以防后期忘记这里的坑。那当时也不算解决吧,我只是在创建@标签后,填充好我所要@的人后,
    用document.execCommand(“innerHTML”, false, ‘’+’ ‘+’')。

  • 3.其实我在做这个项目中,还有截图的问题,最开始截图不是我做的,我的同事一律用了i标签,后来我攻克了截图,因为截图比较容易找闭合的标签,也就是单用img标签,然后我再加入过滤的正则就可以解决,而@我是试了很多其他的闭合标签然后关注到了input,但input的type为text的宽度我试了不好做宽度是自适应,
    所以我换了input的type为button的。
    这无论是截图还是@所有的创建标签都在createElementFun这个公共方法里。我写成了公共的。

闭合标签:

<br />   换行标签
<hr />   横线   (方便排版)
<area />   链接区域  (没能理解 T T )
<base />  基准标签(将对应的URL转到对应的链接目标)
<img />   Image
<input />   输入标签
<link />     用来引用另一个文件的标签
<meta />   定义标签  (让浏览器知道你这个文件是什么格式的)
<basefont />  基准字形标签  (设置整个页面的字体的属性)
<frame />   设置框架的标签  (X,Y,宽,高)
<embed />  多媒体标签  (音乐,视频)
闭合标签原文链接:https://blog.csdn.net/Coco__D/article/details/53287875

过滤标签方法:



/**
 * 处理@、图片、表情等复杂类型的数据,转换成json格式
 * @param htmlStr 当前处理数据的字符串
 * @param flag 判断当前是否是多个标签去处理的,目前主要是用于复制聊天记录中
 */
const setObj = (htmlStr: any) => {
  // eslint-disable-next-line prefer-const
  let obj: any = { type: '', content: '' };
  if (htmlStr.includes('cosmo-img') || htmlStr.includes('cosmo-upload-img')) {
    // 图片
    obj.type = htmlStr.includes('cosmo-img') ? 'image' : 'upload-image';
    // 正则匹配img base64字符串 imgSrc
    const srcReg = /<img[^>]+src=['"]([^'"]+)['"]+/g;
    const content = srcReg.exec(htmlStr);
    const contentNum = content ? content[1].indexOf('/rss/project/repository/') : -1;
    if (contentNum > -1 && content) {
      content[1] = content[1]?.slice(contentNum);
    }
    obj.content = content ? content[1] : '';
  } else if (htmlStr.includes('cosmo-@')) {
    // @
    obj.userId = null;
    obj.type = 'at';
    const srcReg = /\s+userid\s*=\s*"(.*?)"\s+/g;
    const valueReg = /\s+value\s*=\s*"(.*?)"\s+/g;
    // 存在时,按照@处理;不存在按照text处理
    if (htmlStr.match(srcReg)) {
      const userId = htmlStr.match(srcReg)[0].split('=')[1].split('"').join('').trim();
      // const reg = /<[^<>]+>/g;
      const name = htmlStr.match(valueReg)[0].split('=')[1].split('"').join('').trim();
      obj.content = name + ' ';
      obj.userId = !userId || Number(userId) === -1 ? null : Number(userId);
    } else {
      obj.type = 'text';
      obj.content = '@' + ' ';
    }
    // 正则匹配userId及名称name
  } else if (htmlStr.includes('cosmo-emo')) {
    const srcReg = /\s+alt\s*=\s*"(.*?)"\s+/g;
    const content = srcReg.exec(htmlStr);
    const contentNum = content ? `[${content[1]}]` : '';
    // 表情
    obj.type = 'emo';
    // 正则匹配标签内容 expression
    obj.content = contentNum;
  // 正则匹配是@标签,但并不是@功能的处理
  } else if(!htmlStr.includes('cosmo-type=') && htmlStr.includes('<input')) {
    const valueReg = /\s+value\s*=\s*"(.*?)"\s+/g;
    // 存在时,按照@处理;不存在按照text处理
    const name = htmlStr.match(valueReg)[0].split('=')[1].split('"').join('').trim();
    obj.type = 'text';
    obj.content = name;
  }
  return obj;
};

/**
 * 发送消息处理数据为json格式
 * #%BQ#%用来匹配标签 input(@)
 * #%IJ#%用来匹配标签 img图片,表情
 * #%BR#%用来匹配<br>换行
 * #%LJ#%用来匹配link链接
 */
export function chatContentToJson() {
  // 发送框里的数据类型:文字、图片、@、表情、链接、换行
  // 发送框内容
  // eslint-disable-next-line prefer-const
  let text = document.getElementById('textContent');
  const str = text?.innerHTML.replace(/<br>|&nbsp;/g, '').trim();
  if (!str) return false;

  // 匹配html标签
  const htmlReg = /<input[^>]*>/ig;
  // <[^>]+>.*?<[^>]+>
  // 匹配为表情|图片的标签
  const emoHtmlReg = /<img.*?cosmo-type.*?>/gi;
  // 匹配链接
  const linkReg =
    /((http|https):\/\/)(([\w\-]|[a-z0-9]+\.)+([\w\-]|[a-z0-9])+(\:|\/)[\w-/. | \w\u4e00-\u9fa5\-\.\/?\@\%\!\&=\+\~\:\#\;\,]+|[A-Za-z]+.[A-Za-z]+.[A-Za-z]*[\w\-\@?^=%&amp;/~\+#])/gi;

  // @
  const htmlList: any = text?.innerHTML && text?.innerHTML.match(htmlReg)|| [];
  // 获取所有自定义的标签:图片、emo
  const imgList: any = (text?.innerHTML && text?.innerHTML.match(emoHtmlReg)) || [];
  // 链接数组
  const linkArr: any = text ? text.innerHTML.match(linkReg) : [];
  // 先将所有<i cosmo-type ></i>标签替换成#&%&
  let textString: string | undefined = text?.innerHTML.trim();

  // 截图|表情
  textString = textString.replace(/(^\s*)|(\s*$)/g, '').replace(linkReg, '#%LJ#%');
  textString = textString.replace(emoHtmlReg, '#%IM#%');
  // @
  textString = textString.replace(htmlReg, '#%BQ#%');

  // 替换<br>
  textString = textString.replace(/<br>/gi, '#%BR#%');
  textString = textString.replace(/\n/g, '#%IN#%');
  // 替换链接
  // 切割成数组
  const textList = textString.split('#%');

  // 过去空数据,将切割的内容放到newTextList中
  const newTextList = [];
  // 删除空项
  for (let i = 0; i < textList.length; i++) {
    if (textList[i]) {
      newTextList.push(textList[i].trim());
    }
  }

  // htmlIndex
  let htmlIndex: number = 0;
  // emoIndex
  let emoIndex: number = 0;
  // linkIndex
  let linkIndex: number = 0;

  // eslint-disable-next-line prefer-const
  let contentList: IcontentList[] = [];
  for (let i = 0; i < newTextList.length; i++) {
    switch (newTextList[i]) {
      case 'BQ':
        // 图片、@、表情
        contentList.push(setObj(htmlList[htmlIndex]));
        htmlIndex++;
        break;
      case 'IM':
        // 图片、@、表情
        contentList.push(setObj(imgList[emoIndex]));
        emoIndex++;
        break;
      case 'BR':
        // br 换行符
        contentList.push({ type: 'text', content: '<br>' });
        break;
      case 'IN':
        // \n 换行符
        contentList.push({ type: 'text', content: '<br>' });
        break;
      case 'LJ':
        // 链接
        contentList.push({
          type: 'link',
          content: linkArr[linkIndex].replace(/&nbsp/g, '').replace(/&amp;/g, '&'),
        });
        linkIndex++;
        break;
      default:
        // 过滤掉所有的标签
        const reg = /<[^\\>]*>/gi
        contentList.push({ type: 'text', content: newTextList[i].replace(reg, '') });
        break;
    }
  }
  // 得到新数组contentList,发送给后端
  return contentList.filter((item: { content: string }) => !!item.content);
}

总结:自己研发富文本框实在是太多坑了,要自定义富文本的我只能说你们太想不开了,我一点不想自己研发,我领导指定的我没办法,只能入坑,想哭,太想哭了。

/**
* 主要就是用这两个方法去写编辑框以及编辑框联动的 @ 功能
    关键功能:
        const sel: any = window.getSelection();
        1. 一个是输入@时显示@人员列表弹框
            输入时就创建dom元素,然后插入到虚拟dom中,然后都追加到range中,然后重新将焦点定位到当前的内容中
            /**
             * 设置光标位置
             * @param node
             * @param 复用此方法时请注意node元素格式
             */
            export const setCaretPosition = (node: HTMLElement | HTMLImageElement | undefined | null) => {
              if (!node) return;
              const range: any = document.createRange();
              node && range.selectNodeContents(node);
              range.setStartAfter(node);
              range.collapse(true);
              const sel = window.getSelection();
              sel?.removeAllRanges();
              sel?.addRange(range);
              sel?.getRangeAt(0);
            };
            
           /**
             * 点击@或输入@功能时,创建的元素 —— 封装
             * @param selection: {
             *   selection:window.getSelection()
             *   type: 是@还是截图,这里目前仅有@和截图用createElementFun创建
             *   i: 创建@或图片时的计算
             *   url: 图片时要返的路径
             * }
             * @returns atNode 节点
             */
        interface createElementFunType {
          selection: any;
          type: string;
          i: number;
          url: string;
        }
        export const createElementFun = (createVal: createElementFunType) => {
          const { selection, type, i, url } = createVal;
          // 创建标签集合
          const createElementTypeObj = {
            at: () => {
              // 创建at标签
              const nodei = document.createElement('i');
              nodei.style.fontStyle = 'normal';
              nodei.setAttribute('id', 'at' + (i + 1));
              const node = document.createElement('span');
              node.innerHTML = '@';
              node.style.userSelect = 'none';
              nodei.appendChild(node);
              nodei &&
                (nodei.onfocus = (e) => {
                  e.preventDefault();
                });
              return nodei;
            },
            // 创建截图标签
            screenshot: () => {
              const node = document.createElement('img');
              node.setAttribute('cosmo-type', 'cosmo-img');
              node.setAttribute('id', 'screenshotImage' + i);
              node.src = url;
              node.width = 200;
              node.height = 100;
              // 保存原有尺寸比例
              node.style.objectFit = 'cover';
              return node;
            },
          };
        
          // 获取选中的文本范围
          const range = selection.getRangeAt(0).cloneRange();
          const fragment = document.createDocumentFragment();
          // 获取创建好的标签
          const createNode = createElementTypeObj[type]();
          // 追加创建好的标签
          createNode && fragment.appendChild(createNode);
        
          // 插入区域对象
          range.insertNode(fragment);
        
          // 获取要定位的node
          const setRangeNode = {
            at: () => document.getElementById('at' + (i + 1)),
            // 截图因为是img标签,所以要使用
            screenshot: () => document.getElementById('screenshotImage' + i),
          };
        
          // 设置光标定位
          setCaretPosition(setRangeNode[type]());
          return setRangeNode[type]();
        };
        2. 一个是点击@时显示@人员列表弹框
            在点击时需要创建dom元素,填充@并获取焦点
                获取焦点并定位分两种,
                    (1)无内容时,也就是通过selection中的focusOffset和containsNode
                    例:
                    // 判断指针在最开始并且是属于textContent内的元素
                      if (sel.focusOffset === 0 && sel.containsNode(box, true)) {
                          div.focus()                      
                      }
                     (2)有内容时,要判断是否在可编辑的div盒子内,因为selection太灵活了,所以在使用时要限制下
                     例:
                     // 判断光标已经出界 || 属于textContent内的光标,并且不是文本类型和元素节点才会执行
                     else if ((sel.containsNode(box, true) &&sel.focusNode.nodeType !== 3 &&
                      sel.focusNode.nodeType !== 1) ||!sel.containsNode(box, true)) {
                          box.focus();
                          sel.selectAllChildren(box); //range 选择obj下所有子内容
                          sel.collapseToEnd(); //光标移至最后                      
                      }
        3.
*/

js的方法:
    Selection:https://developer.mozilla.org/zh-CN/docs/Web/API/Selection
    Range: https://developer.mozilla.org/zh-CN/docs/Web/API/Range
    
html的方法:
    div可编辑的关键属性:
        contentEditable="true"
        suppressContentEditableWarning
    整体:https://blog.csdn.net/qq_32615575/article/details/119791290
          <div
            suppressContentEditableWarning
            className={styles.textContent}
            contentEditable="true"
            id="textContent"
            onInput={handleChange}
            onDoubleClick={selecteAllDouble}
            onKeyDown={(e) => {
              onkeydown(e);
            }}
          />
    
css:给class和color样式

要是设置输入框双击选中输入框内所有的元素标签,
就不能给单独的@xxx设置css不让选中标签,否则js的全选功能依旧不生效

使用

项目at组件框

注:new Range() = document.createRange()


// 引入滚动条组件
import type { ScrollbarsRef } from '@cosmosource/cs-common';
import { Scrollbars } from '@cosmosource/cs-common';
import { delay } from 'lodash';
import React, { useEffect, useRef, useState } from 'react';
import { connect } from 'umi';
import type { Dispatch } from 'umi';

import Avatar from '@/components/Avatar';
import IconButton from '@/components/IconButton';
import SelectMember from '@/components/SelectMember';

import type { IGroupMemberList } from '@/interfaces/chat';

// 接口
import { getMemberList } from '@/services/chat';

import { moveCursorEnd } from '@/utils/chat';

import styles from '../index.less';

export interface AtProps {
  /** 是否显示@人员列表 */
  atShow: boolean;
  /**弹窗定位 top */
  top: number;
  /**弹窗定位 left */
  left: number;
  /** @容器的id下标 */
  i: number;
  /** 选择人员后的回调 */
  // fillAtContent: (userId: number, userName: string) => void;
  /** 创建元素的定位 */
  createAtElement: () => void;
  /** 是否显示@人员列表 回调 */
  isAtShow: (flag: boolean) => void;
  /** 选中人的id */
  selectId: string;
  dispatch: Dispatch;
  /** 群成员列表 */
  _groupMemberList: IGroupMemberList[];
  /** 群成员列表参数 */
  _groupMemberListParams: {
    page: number;
    pageSize: number;
    total: number;
  };
}

const atImg = require('@/assets/chat/at.png');

/**
 * 设置光标位置
 * @param node
 * @param 复用此方法时请注意node元素格式
 */
export const setCaretPosition = (node: HTMLElement | HTMLImageElement | undefined | null) => {
  if (!node) return;
  const range: any = document.createRange();
  node && range.selectNodeContents(node);
  range.setStartAfter(node);
  range.collapse(true);
  const sel = window.getSelection();
  sel?.removeAllRanges();
  sel?.addRange(range);
  sel?.getRangeAt(0);
};

/**
 * 点击@或输入@功能时,创建的元素 —— 封装
 * @param selection: {
 *   selection:window.getSelection()
 *   type: 是@还是截图,这里目前仅有@和截图用createElementFun创建
 *   i: 创建@或图片时的计算
 *   url: 图片时要返的路径
 * }
 * @returns atNode 节点
 */
interface createElementFunType {
  selection: any;
  type: string;
  i: number;
  url: string;
}
export const createElementFun = (createVal: createElementFunType) => {
  const { selection, type, i, url } = createVal;
  // 创建标签集合
  const createElementTypeObj = {
    at: () => {
      // 创建at标签
      const nodei = document.createElement('input');
      nodei.style.fontStyle = 'normal';
      nodei.type='button';
      nodei.setAttribute('id', 'at' + (i + 1));
      nodei.setAttribute('disabled', 'true');
      nodei.setAttribute('class', 'atlist');
      nodei.value = '@';
      return nodei;
    },
    // 创建截图标签
    screenshot: () => {
      const node = document.createElement('img');
      node.setAttribute('cosmo-type', 'cosmo-img');
      node.setAttribute('id', 'screenshotImage' + i);
      node.src = url;
      node.width = 200;
      node.height = 100;
      // 保存原有尺寸比例
      node.style.objectFit = 'cover';
      return node;
    },
  };

  // 获取选中的文本范围
  const range = selection.getRangeAt(0).cloneRange();
  const fragment = document.createDocumentFragment();
  // 获取创建好的标签
  const createNode = createElementTypeObj[type]();
  // 追加创建好的标签
  createNode && fragment.appendChild(createNode);

  // 插入区域对象
  range.insertNode(fragment);

  // 获取要定位的node
  const setRangeNode = {
    at: () => document.getElementById('at' + (i + 1)),
    // 截图因为是img标签,所以要使用
    screenshot: () => document.getElementById('screenshotImage' + i),
  };

  // 设置光标定位
  setCaretPosition(setRangeNode[type]());
  return setRangeNode[type]();
};

// 中止控制 取消请求时使用
let isSuccess: boolean;

const At: React.FC<AtProps> = (props) => {
  const {
    atShow,
    top,
    left,
    i,
    createAtElement,
    isAtShow,
    selectId,
    dispatch,
    _groupMemberList,
    _groupMemberListParams,
  } = props;
  const ref = useRef<ScrollbarsRef>(null);
  const [open, setOpen] = useState(false);

  /**
   * 艾特成员 回调事件
   * @param data
   */
  const okCallback = (data: any[]) => {
    console.log(data);
    // todo 要艾特的人
  };

  /**
   * @ 点击事件
   */
  const atIconClick = (e: any) => {
    e.stopPropagation();
    const box = document.getElementById('textContent');
    if (box) {
      const sel: any = window.getSelection();

      // 判断指针在最开始并且是属于textContent内的元素
      if (sel.focusOffset === 0 && sel.containsNode(box, true)) {
        box.focus();

        // 判断光标已经出界 || 属于textContent内的光标,并且不是文本类型和元素节点才会执行
      } else if (
        (sel.containsNode(box, true) &&
          sel.focusNode.nodeType !== 3 &&
          sel.focusNode.nodeType !== 1) ||
        !sel.containsNode(box, true)
      ) {
        box.focus();
        // 光标移至到最后
        moveCursorEnd(box);
      }
    }
    /**
     * 创建元素
     */
    createAtElement();
  };

  /**
   * 选择人员后填充@内容
   * @param userId 用户id
   * @param userName 用户名称
   */
  const fillAtContent = (e: any, userId: string, userName: string) => {
    e.stopPropagation();
    const atNode = document.getElementById('at' + i);

    if (atNode) {
      // 必须在回显时才能只展示cosmo-@
      atNode.setAttribute('cosmo-type', 'cosmo-@');
      atNode.setAttribute('userId', userId);
      atNode.style.color = '#1487FB';
      atNode.style.userSelect = 'none';
      // 填充选中人员
      atNode.setAttribute('value', `@${userName}`)
      // 此处加一个空格是为了定位光标位置,如果没有空格,光标位置会定位到最后面
      const box = document.getElementById('textContent');
      box?.append(' ');
      // 隐藏选择人员弹窗
      isAtShow(false);
      // 设置光标位置
      setCaretPosition(atNode);
    }
  };
  /**
   * 群成员列表接口
   */
  const getMemberGroupList = (data: any) => {
    if (!data) return;
    // 群成员列表 —— 接口
    dispatch({
      type: 'chat/getGroupLisk',
      payload: {
        ...data,
        groupId: selectId,
      },
    }).then((data: any) => {
      isSuccess = true;
      dispatch({
        type: 'chat/setGroupLiskParams',
        payload: { ..._groupMemberListParams, total: data.total },
      });
    });
  };

  useEffect(() => {
    if (!(selectId && selectId.includes('group_'))) return;
    dispatch({
      type: 'chat/setGroupLiskParams',
      payload: { ..._groupMemberListParams, total: 0, page: 1 },
    });
    isSuccess = false;
    getMemberGroupList({ ..._groupMemberListParams, page: 1 });
  }, [selectId]);

  // 滚动到底部 加载聊天记录
  useEffect(() => {
    if (_groupMemberListParams.page !== 1) getMemberGroupList(_groupMemberListParams);
  }, [_groupMemberListParams.page]);
  /**
   * 监听滚动条的事件
   */
  const scrollBarsHandler = (e: any) => {
    const clientHeight = e.target.clientHeight;
    const scrollHeight = e.target.scrollHeight;
    const scrollTop = e.target.scrollTop;

    // 滚动条到底部得距离 = 滚动条的总高度 - 可视区的高度 - 当前页面的滚动条纵坐标位置
    const scrollBottom = scrollHeight - clientHeight - scrollTop;
    delay(() => {
      // boolean[]
      const newarr = [
        scrollBottom <= 135,
        Number(_groupMemberListParams.total) > 20,
        Number(_groupMemberListParams.total) > _groupMemberList.length,
      ];
      if (newarr.every((item) => !!item && isSuccess)) {
        dispatch({
          type: 'chat/setGroupLiskParams',
          payload: { ..._groupMemberListParams, page: _groupMemberListParams.page + 1 },
        });
      }
    }, 1000);
  };

  /**
   * 监听点击的区域
   */
  const eventListenerClick = () => {
    isAtShow(false);
  };
  useEffect(() => {
    document.addEventListener('click', eventListenerClick, false);
    return () => {
      document.removeEventListener('click', eventListenerClick, false);
    };
  }, []);

  return (
    <div className={styles.at}>
      <IconButton src={atImg} tips={'试试直接输入 @'} onClick={atIconClick} />

      {atShow && (
        <div
          className={styles.atContent}
          style={{ top: top - 380 + 'px', left: left, zIndex: 9 }}
          onClick={(e: any) => e.stopPropagation()}
        >
          <p className={styles.title}>
            <span>群成员</span>
            {/* <span
              onClick={(e: any) => {
                e.stopPropagation();
                setOpen(true);
              }}
            >
              多选
            </span> */}
          </p>
          <ul
            className={styles.atList}
            // style={{ height: (40 * data.length > 380 ? 380 : 40 * data.length) + 'px' }}
          >
            <Scrollbars ref={ref} className="cosmo-scrollbars" onScroll={scrollBarsHandler}>
              <div className={styles.atListBox}>
                <li key={-1} onClick={(e) => fillAtContent(e, '-1', '所有人')}>
                  <Avatar name={'@'} size={24} />
                  所有人
                </li>
                {_groupMemberList.map((item: IGroupMemberList) => {
                  return (
                    <li
                      key={item.memberId}
                      onClick={(e) => fillAtContent(e, String(item.memberId), item.name)}
                    >
                      <Avatar name={item.name} size={24} />
                      {item.name}
                    </li>
                  );
                })}
              </div>
            </Scrollbars>
          </ul>
        </div>
      )}

      {open && (
        <SelectMember
          title="选择要@的人"
          // 已勾选人
          selectMember={[]}
          // 显示否
          open={open}
          // 设置显示否
          setOpen={setOpen}
          // 点确定回调
          okCallback={okCallback}
          groupId={selectId}
          groupName={''}
        />
      )}
    </div>
  );
};

export default connect(({ chat }: any) => ({
  _groupMemberList: chat.groupMemberList,
  _groupMemberListParams: chat.groupMemberListParams,
}))(At);

项目使用at的send组件


// 引入滚动条组件
import type { ScrollbarsRef } from '@cosmosource/cs-common';
import { Scrollbars } from '@cosmosource/cs-common';
import { Button, message } from 'antd';
import { cloneDeep } from 'lodash';
import React, { useEffect, useRef, useState } from 'react';
import { connect } from 'umi';
import type { Dispatch } from 'umi';

// 数据类型
import type { IChatInfo, IChatListType, IChatRecordType } from '@/interfaces/chat';
import type { IUser } from '@/interfaces/user';

import { sendMessage } from '@/services/chat';

// 枚举
import { MainWindowEnum } from '@/enums';
import type { ConnectState } from '@/models/connect';
// 公共方法
import {
  chatContentToJson,
  isHeaderResetList,
  moveCursorEnd,
  operateVariables,
  reRenderChatList,
  strSize,
} from '@/utils/chat';
import { ipcRenderer, isElectron } from '@/utils/electron';

// 子组件
import At from './components/At';
// 引入@创建的元素方法
import { createElementFun } from './components/At';
// 因需求暂隐
// import Ding from './components/Ding';
import Emo from './components/Emo';
import Screenshot from './components/Screenshot';
import UploadFile from './components/Upload';
import { UploadConfirm } from './components/UploadModal';
// 样式
import styles from './index.less';

// 定义timeout
let timer: NodeJS.Timeout | null = null;

export interface ISendBox {
  dispatch: Dispatch;
  /** 正在发送/失败聊天对象 */
  _loadingChatObj: Record<string, IChatRecordType[]>;
  /** 当前登录人 */
  _currentUser: IUser;
  /** 聊天置顶列表 */
  _topList: string[];
  /** 聊天列表id */
  _chatIdList: string[];
  /** 聊天记录列表 */
  _chatRecordlList: IChatRecordType[];
  /** 聊天列表 */
  _chatList: IChatListType[];
  /** 聊天记录的滚动条 */
  _recordRef: any;
  /** 父组件的ref值 */
  toRef: any;
  /** 选中人的id */
  selectId: string;
}
const SendBox: React.FC<ISendBox> = (props) => {
  const {
    dispatch,
    _currentUser,
    _topList,
    _chatRecordlList,
    _chatList,
    _recordRef,
    toRef,
    selectId,
  } = props;

  const ref = useRef<ScrollbarsRef>(null);
  const selection: any = window.getSelection();
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  const [_sendContent, setSendContent] = useState('');
  // at选择框的显示
  const [atShow, setAtShow] = useState(false);
  // emo选择框的显示
  const [emoShow, setEmoShow] = useState(false);
  // at选择框定位 top
  const [top, setTop] = useState(0);
  // at选择框定位 left
  const [left, setLeft] = useState(0);
  // 每一个@容器的id下标
  const [i, setI] = useState(0);

  // 上传弹框的大小是否超过了2G的限制的方法回调
  const uploadRef = useRef<React.MutableRefObject<any>>(null);

  /**
   * 在光标位置插入
   * @param newStr 要插入的字符
   * @param isHTML 插入的是否是html
   * @param isAddScreenShot 是否是截图(截图暂时只能放在最后)
   */
  const insterStr = (newStr: string | undefined, isHTML: boolean, isAddScreenShot?: boolean) => {
    // 插入截图
    if (isAddScreenShot) {
      // eslint-disable-next-line prefer-const
      let editBox: HTMLElement | null = document.getElementById('textContent');
      editBox && (editBox.innerHTML += newStr);
      return;
    }
    // 谷歌
    if (selection.getRangeAt && selection.rangeCount) {
      let range = selection.getRangeAt(0);
      range.deleteContents();
      const element: any = document.createElement('div');
      if (isHTML) {
        element.innerHTML = newStr;
      } else {
        element.textContent = newStr;
      }
      let node;
      let lastNode;
      const fragment = document.createDocumentFragment();
      while ((node = element.firstChild)) {
        lastNode = fragment.appendChild(node);
      }
      range.insertNode(fragment);
      if (lastNode) {
        range = range.cloneRange();
        range.setStartAfter(lastNode);
        range.collapse(true);
        selection.removeAllRanges();
        selection.addRange(range);
      }
    }
  };

  /**
   * 封装 img标签添加(待优化)
   * @param isParse 是否是粘贴
   */
  let imgI = 0;
  const addImg = (message: any, isParse?: boolean) => {
    // 窗口恢复
    if (isElectron && !isParse) {
      ipcRenderer.send(MainWindowEnum.CUTIMGWINDOW);
    }
    // 截图或粘贴失败或取消截取
    if (!message) {
      return;
    }

    const box = document.getElementById('textContent');
    box?.focus();
    if (box && selection.containsNode(box, true)) {
      imgI++;
      createElementFun({ selection, type: 'screenshot', i: imgI, url: message });
    }
  };

  /**
   * 封装 @ 符号出现时,要创建的元素
   */
  const createAtElement = () => {
    const box = document.getElementById('textContent');

    if (selection.containsNode(box, true)) {
      // 调用创建dom元素方法
      const atNode = createElementFun({ selection, type: 'at', i, url: '' });

      // 设置@容器的id下标
      setI(i + 1);

      // 显示at弹窗
      setAtShow(true);
      if (box && atNode) {
        setTop(atNode.offsetTop || 0);
        // 聊天窗口的宽度
        const boxWidth = box.clientWidth;
        // 返回创建的node的left距离
        const nodeLeft = atNode.offsetLeft;
        // 距离窗口的右侧位置
        const windowLeft = atNode.getBoundingClientRect().right;
        // @人员列表弹框展示位置
        setLeft(windowLeft >= boxWidth ? nodeLeft - 200 : nodeLeft || 0);
      }
    }
  };

  /**
   * 监听发送框输入事件
   * @param e
   */
  const handleChange = (e: any) => {
    setSendContent(e.target.innerHTML);
    // 最新一条输入的文字
    const newesttext = e.nativeEvent.data;
    // 如果输入@则显示成员列表 todo
    if (newesttext == '@') {
      timer && clearTimeout(timer);
      timer = setTimeout(() => {
        //创建selection
        const range = selection?.getRangeAt(0);
        //选中输入的@符号
        if (selection?.focusNode && range) {
          // 选中光标所在节点
          range?.setStart(selection?.focusNode, selection?.focusOffset - 1);
          range?.setEnd(selection?.focusNode, selection?.focusOffset);
          //删除输入的@符号
          range?.deleteContents();
          selection?.removeAllRanges();
          selection?.addRange(range);
          // 创建at元素标签,此时创建是因为需要定位光标位置,方便选择后插入
          createAtElement();
        }
      }, 0);
    } else {
      // 隐藏at弹窗
      setAtShow(false);
    }
  };

  /**
   * 输入框 双击全选功能
   */
  const selecteAllDouble = () => {
    const box = document.getElementById('textContent');
    //创建selection
    if (box) {
      const range = document.createRange();
      box && range.selectNodeContents(box);
      range.collapse(false);
      // 光标移至到最后
      moveCursorEnd(box);
    }
  };

  /**
   * 发送消息
   * @param flag 是否为文件回调回来的
   * @param arr 是否为文件回调回来的数据
   */
  const send = (list?: {
    flag?: boolean;
    arr?: any[];
    fileId?: string;
    newDataList?: IChatRecordType;
  }) => {
    try {
      const { flag, arr, fileId, newDataList } = list || {};
      const id: any = fileId ? fileId : operateVariables.getSelectId();
      if (!id) return;
      // 关闭@弹窗
      setAtShow(false);
      // 获取输入文本
      let sendData: IChatInfo[] | boolean;
      if (flag && arr) {
        sendData = arr;
      } else {
        sendData = chatContentToJson();
      }
      // console.log(sendData, '======-------------sendData')
      // 清空发送框
      const editBox: HTMLElement | null = document.getElementById('textContent');
      // 数据为空不发送 TODO改成气泡形式的展示
      if (!sendData) {
      // message.warning('数据为空');
        return editBox && (editBox.innerHTML = '');
      }
      // 获取正在发送的数据
      const loadingChatObj: Record<string, IChatRecordType[]> | boolean =
      operateVariables.getLoadingChatObj();
      // 设置发送消息----todo(设置发送消息内容)

      let newData: any = {
        timestamp: new Date().getTime() + '', // timestamp  -----在后端返回时可作为判断依据,是否是当前发送消息
        receiveTime: '', // 发送时间
        // content: sendData,
        message: sendData, // 最新一条消息内容预览
        from: _currentUser.userId + '',
        fromName: _currentUser.name,
        to: id,
        toName: '',
        toProfile: '',
        id: new Date().getTime() + '', // 消息id
        isSelf: true, // 该消息是否为自己发出
        fromProfile: _currentUser.profile, // 头像链接
        type: 1, // 消息类型
        state: false, // 是否已读
        fileType: '',
        isReply: false, // 是否为回复
        error: false, // 是否发送失败
        isTop: _topList.includes(id),
        atEnabled: sendData.some((item) => {
          return (
            (item.type == 'at' && String(item.userId) === _currentUser.userId) ||
          (item.type == 'at' && item.content.trim() === '@所有人')
          );
        }),
      };

      if (fileId && newDataList) {
        newData = newDataList;
      } else {
      // 更新正在发送/失败的数据_loadingChatObj
        loadingChatObj[id]
          ? (loadingChatObj[id] = [...loadingChatObj[id], newData])
          : (loadingChatObj[id] = [newData]);
        // 设置发送数据
        operateVariables.setLoadingChatObj(loadingChatObj, newData.to, dispatch);
      }
      // 滚到到底部
      setTimeout(() => {
        _recordRef.current.scrollToBottom();
      }, 100);

      editBox && (editBox.innerHTML = '');
      // 调用接口发送信息----后端需要将收消息人的id(to)及当前消息id(id)返回前端(此id应与前端发送时一致,用于删除数据)
      const param: any = {
        to: id,
        type: '1', // 消息类型: 1普通消息;2转发;3回复;4ding;5窗口内通知
        message: sendData,
        timestamp: newData.timestamp, // 用于删除loading列表的数据
        isSelf: true,
      };
      // 接口发送信息
      sendMessage(cloneDeep(param)).then((res: any) => {
      // 发送成功
        if (res && res.data) {
        // 过滤发送成功的对象数据
          const fileterLoadingChatObj = loadingChatObj[res.data.to];
          const newList = []; // 新正在发送列表
          let successChat: any = {}; // 发送成功的数据
          for (let i = 0; i < fileterLoadingChatObj.length; i++) {
            if (fileterLoadingChatObj[i].timestamp === res.data.timestamp) {
            // 从fileterLoadingChatObj删除已经发送成功的数据
              successChat = fileterLoadingChatObj[i];
            } else {
            // 更新成功的loading数据
              newList.push(fileterLoadingChatObj[i]);
            }
          }

          delete successChat.error;
          successChat.id = res.data.id;
          successChat.message = res.data.message;
          // 发送时间
          successChat.sendTime = res.data.sendTime;
          successChat.receiveTime = res.data.receiveTime;
          // 判断头像是否已更改
          if (
            _chatRecordlList.length &&
          _chatRecordlList[_chatRecordlList.length - 1].fromProfile !== res.data.fromProfile
          ) {
            dispatch({
              type: 'chat/setChatRecordlList',
              payload: isHeaderResetList(_chatRecordlList, res.data, dispatch),
            });
          }

          // 先判断上传的id不存在,并且返回的id等于选中的id时执行
          if (!fileId && res.data.to === operateVariables.getSelectId()) {
          // 在不是上传的时候更新已经发送完的数据信息
            dispatch({
              type: 'chat/addChatRecordlList',
              payload: [successChat],
            });
            reRenderChatList(successChat, dispatch, _chatList, _topList);
          } else if (fileId === operateVariables.getSelectId()) {
          // 上传的id等于选中的id时执行
            dispatch({
              type: 'chat/addChatRecordlList',
              payload: [successChat],
            });
          }

          // 更新数据, 也是删除
          loadingChatObj[res.data.to] = newList;
        } else {
          loadingChatObj[param.to] = loadingChatObj[param.to].map((item) => {
            if (item.timestamp === param.timestamp) {
              item.error = true;
            }
            return item;
          });
        }

        // 更新正在发送消息对象
        operateVariables.setLoadingChatObj(
          loadingChatObj,
          res?.data ? res.data.to : param.to,
          dispatch,
        );
        // 滚到到底部
        _recordRef?.current?.scrollToBottom();
      });
    } catch (error) {
      console.error(error);
    }
  };

  /**
   * 监听编辑的按下的键盘事件
   * 按回车发送信息
   */
  // 用于判断第一次的ctrl折行问题
  const [keyNum, setKeyNum] = useState(0);
  const onkeydown = (e: any) => {
    const editBox: HTMLElement | null = document.getElementById('textContent');
    // 换行
    const reg = /<br>$/;
    if (e.ctrlKey && e.which === 13) {
      // 针对第二次换行不显示的问题处理
      if (keyNum > 0 && editBox && !reg.test(editBox?.innerHTML)) {
        insterStr('<br>', true);
      }
      insterStr('<br>', true);
      // 用于判断第一次的ctrl折行问题
      setKeyNum(() => keyNum + 1);
      return false;
    } else if (e.altKey && e.which === 13) {
      // eslint-disable-next-line prefer-const
      // 不以<br>为结尾,则添加两个,否则不换行
      if (editBox && !reg.test(editBox?.innerHTML)) {
        editBox.innerHTML += '<br>';
      }
      editBox && (editBox.innerHTML += '<br>');
      // 光标定位到最后
      moveCursorEnd(editBox);
      return false;
    } else if (e.which === 13 && e.key === 'Enter') {
      // 发送消息
      send();
      return e.preventDefault();
    }
    return false;
  };

  /**
   * 使textContent不失焦
   */
  const textContentFocus = () => {
    // if (e.target.id !== 'textContent') {
    //   e.preventDefault();
    // }
  };

  /**
   * 粘贴方法
   */
  const textContentPaste = async (e: any) => {
    // 粘贴纯文本,不要样式
    const pastext = e.clipboardData.getData('text/plain');
    if (pastext) {
      // 计算字符串大小
      const size = strSize(pastext);
      // 大于2G,发送文件
      if (size / 1024 > 1024 * 1024 * 1024) {
        // 文件内容
        // const newstr = pastext;
        // 生成file文件
        // const fileContent = new File([newstr], '文本.txt', { type: '' });
        // const files = [fileContent];
        // 文件上传
        // uploadOption(null, files)
      } else {
        // 这里截取处理:区分一下网页里复制的图文和聊天记录右击复制的图文,如果是聊天记录右击复制的图文我们需要复制上图片,如果是网页的,我们不需要图片,只要文字
        // 匹配div标签
        const pastextSplit = pastext.search(/<i.*?cosmo-type.*?>.*?<\/i>/gi);
        // 匹配表情
        const emoSplit = pastext.search(/<img.*?cosmo-type.*?>/gi);
        if (pastextSplit >= 0 || emoSplit >= 0) {
          // 将内容插入进去
          insterStr(pastext, true);
        } else {
          insterStr(pastext, true);
        }
      }
      e.preventDefault();
    }

    // 粘贴图片/文件
    const pasteItems = e.clipboardData && e.clipboardData.items;
    // 图片文件内容
    let imgObj: any;
    // 文件内容
    let fileObj;
    if (pasteItems && pasteItems.length) {
      for (let i = 0; i < pasteItems.length; i++) {
        // 图片
        if (pasteItems[i].type.indexOf('image') > -1) {
          // 获取图片文件
          imgObj = await pasteItems[i].getAsFile();
        } else {
          fileObj = await pasteItems[i].getAsFile();
        }
      }
    }
    // 粘贴图片
    if (imgObj) {
      const fileReader = new FileReader();
      fileReader.readAsDataURL(imgObj);
      const file = imgObj;
      const { name } = imgObj
      fileReader.onload = (e: any) => {
        // 大于20M(20MB),使用上传
        if (e.target.result) {
          if (name.match(/[&=+\/:*?<>'"'"|\s]+/)) {
            message.error('文件名称包含特殊字符([&=+/:*?<>\'"\'"|s)');
            return
          }
          if (e.total > 20 * 1024 * 1024) {
            /**
             * 大于20M(20MB)的弹框,点击确定时要上传的内容
             */
            UploadConfirm({
              uploadHandler: (uploadRef.current as any).uploadHandler,
              file,
            });
          } else {
            if (name.match(/[&=+\/:*?<>'"'"|\s]+/)) {
              message.error('文件名称包含特殊字符([&=+/:*?<>\'"\'"|s)');
              return
            }
            // 添加图片
            addImg(e.target.result, true);
          }
        }
      };
      e.preventDefault();
    }

    // 粘贴文件
    if (fileObj) {
      // 调用上传文件方法,上传文件
    }

    // 阻止默认行为,因为从钉钉撤销【重新编辑】中复制图片,粘到我们文本框时没有宽高,发送后导致聊天有了横滚
    e.preventDefault();
  };

  useEffect(() => {
    const chatTextArea: HTMLElement | null = document.getElementById('textContent');
    // 监听聊天框粘贴事件
    chatTextArea?.addEventListener('paste', textContentPaste, false);
    // 使发送框不失焦
    document.addEventListener('mousedown', textContentFocus, false);
    return () => {
      document.removeEventListener('mousedown', textContentFocus, false);
      chatTextArea?.removeEventListener('paste', textContentPaste, false);
    };
  }, []);

  useEffect(() => {
    const list = document.getElementById('operatList');
    if (list) {
      /**
       * 禁止右击
       */
      list.oncontextmenu = (e) => {
        return (e.returnValue = false);
      };
    }
  }, []);

  useEffect(() => {
    setAtShow(false);
  }, [selectId]);

  /**
   * 子组件暴露给父组件的方法
   */
  React.useImperativeHandle(toRef, () => ({
    send,
  }));

  return (
    <div className={styles.sendBox}>
      {/* 操作栏 */}
      <div className={styles.operate} id={'operatList'}>
        <Emo emoShow={emoShow} setEmoShow={setEmoShow} />
        {isElectron && <Screenshot addImg={addImg} />}
        {selectId.includes('group_') && (
          <At
            atShow={atShow}
            isAtShow={setAtShow}
            top={top}
            left={left}
            i={i}
            createAtElement={createAtElement}
            selectId={selectId}
          />
        )}
        {/* 该功能未开发,暂隐 */}
        {/* <Ding userInfo={{}} /> */}
        <UploadFile send={send} selectId={selectId} uploadRef={uploadRef} />
      </div>
      {/* 可编辑div */}
      <div className={styles.content}>
        <Scrollbars ref={ref} className="cosmo-scrollbars">
          <div
            suppressContentEditableWarning
            className={styles.textContent}
            contentEditable="true"
            id="textContent"
            onInput={handleChange}
            onDoubleClick={selecteAllDouble}
            onKeyDown={onkeydown}
            onBlur={() => {
              // 失去焦点
              setEmoShow(false);
            }}
          />
        </Scrollbars>
      </div>
      <span className={styles.tips}>enter发送&nbsp;/&nbsp;ctrl+enter换行</span>
      <Button className={styles.sendBtn} type="primary" onClick={() => send()}>
        发送
      </Button>
    </div>
  );
};

export default connect(({ chat, user }: ConnectState) => ({
  _loadingChatObj: chat.loadingChatObj,
  _currentUser: user.currentUser,
  _topList: chat.topList,
  _chatRecordlList: chat.chatRecordlList,
  _chatList: chat.chatList,
  _chatIdList: chat.chatIdList,
  _recordRef: chat.recordRef,
}))(SendBox);

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值