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这个公共方法里。我写成了公共的。

闭合标签:


换行标签
横线 (方便排版) 链接区域 (没能理解 T T ) 基准标签(将对应的URL转到对应的链接目标) Image 输入标签 用来引用另一个文件的标签 定义标签 (让浏览器知道你这个文件是什么格式的) 基准字形标签 (设置整个页面的字体的属性) 设置框架的标签 (X,Y,宽,高) 多媒体标签 (音乐,视频) 闭合标签原文链接: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 = /]+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('换行
 * #%LJ#%用来匹配link链接
 */
export function chatContentToJson() {
  // 发送框里的数据类型:文字、图片、@、表情、链接、换行
  // 发送框内容
  // eslint-disable-next-line prefer-const
  let text = document.getElementById('textContent');
  const str = text?.innerHTML.replace(/
| /g, '').trim(); if (!str) return false; // 匹配html标签 const htmlReg = /]*>/ig; // <[^>]+>.*?<[^>]+> // 匹配为表情|图片的标签 const emoHtmlReg = //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\-\@?^=%&/~\+#])/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) : []; // 先将所有标签替换成#&%& 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#%'); // 替换
textString = textString.replace(/
/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: '
' }); break; case 'IN': // \n 换行符 contentList.push({ type: 'text', content: '
' }); break; case 'LJ': // 链接 contentList.push({ type: 'link', content: linkArr[linkIndex].replace(/ /g, '').replace(/&/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
          
{ 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 = (props) => {
  const {
    atShow,
    top,
    left,
    i,
    createAtElement,
    isAtShow,
    selectId,
    dispatch,
    _groupMemberList,
    _groupMemberListParams,
  } = props;
  const ref = useRef(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 (
    
{atShow && (
e.stopPropagation()} >

群成员 {/* { e.stopPropagation(); setOpen(true); }} > 多选 */}

    380 ? 380 : 40 * data.length) + 'px' }} >
  • fillAtContent(e, '-1', '所有人')}> 所有人
  • {_groupMemberList.map((item: IGroupMemberList) => { return (
  • fillAtContent(e, String(item.memberId), item.name)} > {item.name}
  • ); })}
)} {open && ( )}
); }; 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;
  /** 当前登录人 */
  _currentUser: IUser;
  /** 聊天置顶列表 */
  _topList: string[];
  /** 聊天列表id */
  _chatIdList: string[];
  /** 聊天记录列表 */
  _chatRecordlList: IChatRecordType[];
  /** 聊天列表 */
  _chatList: IChatListType[];
  /** 聊天记录的滚动条 */
  _recordRef: any;
  /** 父组件的ref值 */
  toRef: any;
  /** 选中人的id */
  selectId: string;
}
const SendBox: React.FC = (props) => {
  const {
    dispatch,
    _currentUser,
    _topList,
    _chatRecordlList,
    _chatList,
    _recordRef,
    toRef,
    selectId,
  } = props;

  const ref = useRef(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>(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 | 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 = /
$/; if (e.ctrlKey && e.which === 13) { // 针对第二次换行不显示的问题处理 if (keyNum > 0 && editBox && !reg.test(editBox?.innerHTML)) { insterStr('
', true); } insterStr('
', true); // 用于判断第一次的ctrl折行问题 setKeyNum(() => keyNum + 1); return false; } else if (e.altKey && e.which === 13) { // eslint-disable-next-line prefer-const // 不以
为结尾,则添加两个,否则不换行 if (editBox && !reg.test(editBox?.innerHTML)) { editBox.innerHTML += '
'; } editBox && (editBox.innerHTML += '
'); // 光标定位到最后 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>/gi); // 匹配表情 const emoSplit = pastext.search(//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 (
{/* 操作栏 */}
{isElectron && } {selectId.includes('group_') && ( )} {/* 该功能未开发,暂隐 */} {/* */}
{/* 可编辑div */}
{ // 失去焦点 setEmoShow(false); }} />
enter发送 / ctrl+enter换行
); }; 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);

你可能感兴趣的:(JavaScript,javascript,开发语言,ecmascript,前端)