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);